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 addressdate-time— ISO 8601 date-timedate— YYYY-MM-DDtime— HH:MM:SSuri— Absolute URIuri-reference— URI or relative referenceuuid— RFC 4122 UUID (any version)hostname— Hostname (includes IDNA/punycode A-label validation per RFC 5891)ipv4/ipv6— IP addressesregex— ECMA-262 regular expressionjson-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": {
"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.jsontests format as a pure annotation (the Draft 2020-12 default). Under annotation semantics,"format": "uuid"never rejects invalid values.tests/draft2020-12/optional/format/*.jsontests 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
- Spec reference — Event schema definition
- JSONPath reference — Accessing schema data in handlers