JSON Schema

j17 validates event data against JSON Schema Draft 2020-12. Each event type in your spec can have a schema that validates the event's data field. Events with invalid data are rejected before the handler runs.

Supported keywords

Type validation

{
  "type": "object"
}

Types: object, array, string, number, integer, boolean, null

Multi-type syntax is also supported:

{
  "type": ["string", "null"]
}

Enumerations

{
  "enum": ["pending", "processing", "completed", "failed"]
}

Or a single fixed value:

{
  "const": "active"
}

Object properties

{
  "type": "object",
  "properties": {
    "name": { "type": "string" },
    "age": { "type": "integer" },
    "email": { "type": "string", "format": "email" }
  },
  "required": ["name", "email"],
  "additionalProperties": false
}
Keyword Description
properties Define fields and their schemas
required Array of required field names
additionalProperties false to reject unknown fields, or a schema to validate them against. Excludes keys matched by properties and patternProperties
minProperties Minimum number of properties
maxProperties Maximum number of properties

patternProperties

Validate properties whose names match a regex pattern:

{
  "type": "object",
  "properties": {
    "name": { "type": "string" }
  },
  "patternProperties": {
    "^x-": { "type": "string" }
  },
  "additionalProperties": false
}

Properties matching ^x- (like x-custom, x-label) are validated against the given schema. Properties matched by either properties or patternProperties are excluded from additionalProperties checks.

propertyNames

Constrain the names of properties (not their values):

{
  "type": "object",
  "propertyNames": {
    "pattern": "^[a-z_]+$",
    "maxLength": 30
  }
}

Every property name in the object must match the given schema. Useful for enforcing naming conventions.

dependentRequired

If a property exists, other properties become required:

{
  "type": "object",
  "properties": {
    "credit_card": { "type": "string" },
    "billing_address": { "type": "string" },
    "cvv": { "type": "string" }
  },
  "dependentRequired": {
    "credit_card": ["billing_address", "cvv"]
  }
}

If credit_card is present, billing_address and cvv must also be present.

dependentSchemas

If a property exists, the entire object must also match an additional schema:

{
  "type": "object",
  "properties": {
    "type": { "enum": ["personal", "business"] },
    "name": { "type": "string" }
  },
  "dependentSchemas": {
    "type": {
      "if": {
        "properties": { "type": { "const": "business" } }
      },
      "then": {
        "properties": {
          "tax_id": { "type": "string" }
        },
        "required": ["tax_id"]
      }
    }
  }
}

String constraints

{
  "type": "string",
  "minLength": 3,
  "maxLength": 100,
  "pattern": "^[a-zA-Z0-9]+$",
  "format": "email"
}
Keyword Description
minLength/maxLength Character count bounds
pattern Regex pattern match (see regex engine note)
format Predefined format validation (see below)

Supported formats:

  • email — Email address
  • date-time — ISO 8601 date-time
  • date — YYYY-MM-DD
  • time — HH:MM:SS
  • uri — Absolute URI
  • uri-reference — URI or relative reference
  • uuid — RFC 4122 UUID (any version)
  • hostname — Hostname (includes IDNA/punycode A-label validation per RFC 5891)
  • ipv4 / ipv6 — IP addresses
  • regex — ECMA-262 regular expression
  • json-pointer — JSON Pointer (RFC 6901)

Note: format is treated as an assertion (validator), not an annotation. Invalid formats are rejected. See compliance testing for details.

Numeric constraints

{
  "type": "number",
  "minimum": 0,
  "maximum": 1000,
  "exclusiveMaximum": 1000,
  "multipleOf": 0.01
}
Keyword Description
minimum/maximum Inclusive bounds
exclusiveMinimum/exclusiveMaximum Exclusive bounds
multipleOf Number must be a multiple of this value

Array constraints

{
  "type": "array",
  "items": { "type": "string" },
  "minItems": 1,
  "maxItems": 10,
  "uniqueItems": true
}
Keyword Description
items Schema applied to all array items
prefixItems Schemas for specific array positions (tuple validation)
minItems/maxItems Array length bounds
uniqueItems All items must be distinct
contains At least one item must match the given schema
minContains Minimum number of items matching contains
maxContains Maximum number of items matching contains

prefixItems (tuple validation)

Validate specific positions in an array:

{
  "type": "array",
  "prefixItems": [
    { "type": "string" },
    { "type": "integer" },
    { "type": "boolean" }
  ],
  "items": false
}

With "items": false, the array must have exactly three elements matching the positional schemas. Without it, additional items are allowed and unconstrained (or validated by the items schema).

contains

Require that at least one array item matches:

{
  "type": "array",
  "contains": { "type": "string", "minLength": 1 }
}

Combined with minContains/maxContains for range checks:

{
  "type": "array",
  "contains": { "type": "integer", "minimum": 100 },
  "minContains": 2,
  "maxContains": 5
}

At least 2 and at most 5 items must be integers >= 100.

Composition

allOf

Must match all schemas:

{
  "allOf": [
    { "type": "object" },
    {
      "properties": {
        "id": { "type": "string" }
      }
    },
    {
      "properties": {
        "created_at": { "type": "integer" }
      }
    }
  ]
}

anyOf

Must match at least one:

{
  "anyOf": [
    { "type": "string" },
    { "type": "integer" }
  ]
}

oneOf

Must match exactly one:

{
  "oneOf": [
    {
      "properties": { "type": { "const": "email" } },
      "required": ["email"]
    },
    {
      "properties": { "type": { "const": "sms" } },
      "required": ["phone"]
    }
  ]
}

not

Must not match:

{
  "not": { "type": "null" }
}

Conditionals

if/then/else

{
  "properties": {
    "shipping_method": { "enum": ["standard", "express"] }
  },
  "if": {
    "properties": {
      "shipping_method": { "const": "express" }
    }
  },
  "then": {
    "properties": {
      "express_fee": { "type": "number", "minimum": 0 }
    },
    "required": ["express_fee"]
  }
}

If shipping_method is express, then express_fee is required.

References

$defs and $ref

Define reusable schemas:

{
  "$defs": {
    "address": {
      "type": "object",
      "properties": {
        "street": { "type": "string" },
        "city": { "type": "string" },
        "zip": { "type": "string" }
      },
      "required": ["street", "city", "zip"]
    }
  },
  "type": "object",
  "properties": {
    "shipping_address": { "$ref": "#/$defs/address" },
    "billing_address": { "$ref": "#/$defs/address" }
  }
}

Only local $ref within $defs is supported. Remote URLs ($ref: "https://...") are not supported.

Common patterns

Timestamp

{
  "timestamp": {
    "type": "integer",
    "description": "Unix epoch seconds"
  }
}

Money

{
  "amount": {
    "type": "number",
    "minimum": 0,
    "multipleOf": 0.01,
    "description": "Amount in dollars"
  }
}

UUID

{
  "id": {
    "type": "string",
    "format": "uuid"
  }
}

Email

{
  "email": {
    "type": "string",
    "format": "email"
  }
}

URL

{
  "website": {
    "type": "string",
    "format": "uri"
  }
}

Nested object

{
  "profile": {
    "type": "object",
    "properties": {
      "name": { "type": "string" },
      "avatar": { "type": "string", "format": "uri" }
    },
    "required": ["name"]
  }
}

Array of objects

{
  "items": {
    "type": "array",
    "items": {
      "type": "object",
      "properties": {
        "sku": { "type": "string" },
        "quantity": { "type": "integer", "minimum": 1 }
      },
      "required": ["sku", "quantity"]
    }
  }
}

Nullable field

{
  "properties": {
    "middle_name": {
      "anyOf": [
        { "type": "string" },
        { "type": "null" }
      ]
    }
  }
}

Or using multi-type syntax:

{
  "properties": {
    "middle_name": {
      "type": ["string", "null"]
    }
  }
}

Conditional required fields

{
  "type": "object",
  "properties": {
    "payment_type": { "type": "string", "enum": ["card", "bank", "crypto"] },
    "card_number": { "type": "string" },
    "bank_account": { "type": "string" },
    "wallet_address": { "type": "string" }
  },
  "required": ["payment_type"],
  "allOf": [
    {
      "if": { "properties": { "payment_type": { "const": "card" } } },
      "then": { "required": ["card_number"] }
    },
    {
      "if": { "properties": { "payment_type": { "const": "bank" } } },
      "then": { "required": ["bank_account"] }
    },
    {
      "if": { "properties": { "payment_type": { "const": "crypto" } } },
      "then": { "required": ["wallet_address"] }
    }
  ]
}

Reusable definitions

{
  "$defs": {
    "money": {
      "type": "object",
      "properties": {
        "amount": { "type": "integer", "minimum": 0 },
        "currency": { "type": "string", "pattern": "^[A-Z]{3}$" }
      },
      "required": ["amount", "currency"]
    }
  },
  "type": "object",
  "properties": {
    "subtotal": { "$ref": "#/$defs/money" },
    "tax": { "$ref": "#/$defs/money" },
    "total": { "$ref": "#/$defs/money" }
  },
  "required": ["subtotal", "total"]
}

Regex engine

The pattern keyword uses the mvzr regex engine (a Zig-native implementation). j17 carries a patched version that fixes a bug with (group)* zero-matches after greedy quantifiers. Track the upstream repository for fixes — if a new release includes the patch, the vendored copy can be updated.

mvzr supports standard regex syntax. If you encounter unexpected behavior with complex patterns, test against the mvzr engine directly or simplify the pattern.

Unsupported keywords

Using these keywords will reject the spec at upload time:

Keyword Reason
unevaluatedProperties Requires cross-subschema evaluation tracking
unevaluatedItems Requires cross-subschema evaluation tracking
$dynamicRef / $dynamicAnchor Dynamic reference resolution not supported
$anchor Named schema anchors not supported (use $defs + $ref instead)
$vocabulary Vocabulary declarations not supported

Remote $ref (URLs like $ref: "https://...") is also not supported. All schemas must be inline or defined in $defs.

Annotation-only keywords

These keywords are accepted but have no validation effect (parsed and ignored):

Keyword Description
contentEncoding Content encoding hint (e.g., base64)
contentMediaType Content media type hint (e.g., application/json)
contentSchema Schema for decoded content

Per the JSON Schema spec, these are annotations — they describe content but do not constrain it.

Validation errors

When validation fails, the API returns a 400 error with details:

{
  "ok": false,
  "error": {
    "code": "validation_failed",
    "message": "Event data failed schema validation",
    "details": {
      "field": "email",
      "constraint": "format",
      "expected": "email",
      "received": "not-an-email"
    }
  }
}

Common errors:

Error Fix
required Add missing field
type Wrong data type (string vs number)
format Invalid format (malformed email)
additionalProperties Remove unexpected field
enum Value not in allowed list

Compliance testing

The implementation is tested against the official JSON Schema Test Suite for Draft 2020-12. The test harness embeds the official fixture files directly.

Keyword tests

44 of the 46 required keyword test files from the suite's tests/draft2020-12/ directory are run. Tests for unsupported keywords (unevaluatedProperties, unevaluatedItems, $dynamicRef, $anchor, $vocabulary) are skipped at parse time rather than failing.

Within test files that are run, individual test groups using unsupported features (e.g. remote $ref, $dynamicRef) are also skipped. The harness tracks these as "skipped" rather than "failed".

Format tests

The official suite has two sets of format tests:

  • tests/draft2020-12/format.json tests format as a pure annotation (the Draft 2020-12 default). Under annotation semantics, "format": "uuid" never rejects invalid values.

  • tests/draft2020-12/optional/format/*.json tests format as a validator (assertion semantics). These expect invalid formats to be rejected.

j17 uses the optional/format/ suite. Our implementation treats format as an assertion — invalid formats are rejected — which is the behavior users expect from a validation layer. This matches the format-assertion vocabulary from the spec and is explicitly supported by Draft 2020-12 as an implementation choice.

Known limitations

One known test failure remains: the vocabulary test suite includes a test for custom metaschemas with no validation vocabulary, which requires remote $schema resolution (not supported).

All hostname format tests pass, including IDNA/punycode A-label validation (RFC 5891). The implementation includes a full punycode decoder (RFC 3492) and IDNA2008 codepoint classification with contextual rules.

Best practices

Be strict. Use additionalProperties: false to catch typos.

Add descriptions. Help future you (and your team) understand the schema.

Version carefully. Add fields as optional first. Make required after confirming no old events lack them.

Validate early. Use the CLI to check schemas before deploying:

j17 spec validate spec.json

Keep schemas small. Large nested schemas are hard to reason about. Break into referenced components with $defs.

Limitations summary

Limitation Impact
No remote $ref All schemas must be inline or in $defs
No dynamic refs $dynamicRef/$dynamicAnchor not supported
No $anchor Named anchors not supported (use $defs + $ref instead)
No unevaluated* Cannot catch unvalidated properties/items across composition
mvzr regex engine pattern uses mvzr, not PCRE or ECMA-262 (see regex engine note)

For complex validation beyond JSON Schema capabilities, validate in your application before submitting events.

Open source

j17's JSON Schema validation is powered by zig-jsonschema, an open source Zig implementation of Draft 2020-12.

See also

Can't find what you need? support@j17.app