JSON Schema Discriminator: oneOf, Ajv, and OpenAPI
Last updated:
A discriminator is a single property in a JSON object that determines which schema in a oneOf or anyOf array should validate it — eliminating the need to try every branch when the correct one is identified by a tag field. Instead of checking all variants, the validator reads the discriminator value, finds the matching branch, and validates only that branch.
JSON Schema 2020-12 does not have a built-in discriminator keyword; the pattern is implemented with if/then or oneOf + const. OpenAPI 3.x adds a non-standard discriminator object that Ajv does not support natively — it is a hint for code generators and documentation tools, not a validation rule.
This guide covers how to implement discriminated unions in pure JSON Schema with oneOf + const, in OpenAPI 3.x with the discriminator object, and in TypeScript with Zod's discriminatedUnion(). Each approach is shown with working code and a comparison of performance trade-offs.
What Is a Discriminated Union?
A discriminated union is a set of mutually exclusive object schemas that share a single tag field — the discriminator property — whose value uniquely identifies which variant applies. The term comes from type theory (also called a "tagged union" or "sum type") and is widely used in TypeScript, Rust, Haskell, and Elm.
The discriminator property is always a literal: a string const like "circle", "payment.succeeded", or "user.created". Because no two variants share the same literal, the validator can identify the correct branch in O(1) time rather than trying every branch.
Common real-world use cases include:
- Webhook event payloads — a single endpoint receives
payment.succeeded,user.created,subscription.cancelled, each with different required fields - API response envelopes —
{status: "ok", data: ...}vs{status: "error", code: ..., message: ...} - Shape or geometry types —
circleneeds a radius,rectneeds width and height,polygonneeds a vertex array - Notification types — each notification kind has a different payload schema but all share a
typeandtimestampbase
Without a discriminator, oneOf requires testing every branch — O(n) — and produces an unhelpful "must match exactly one schema in oneOf" error when validation fails. With a discriminator const, only one branch can ever match a given input, making errors specific and the schema self-documenting.
Implement with oneOf + const in Pure JSON Schema
The canonical JSON Schema approach to discriminated unions is oneOf where each branch constrains the discriminator property with const. Because each const value is unique, the branches are guaranteed to be mutually exclusive — exactly what oneOf requires.
// Webhook event — three discriminated variants
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"oneOf": [
{
"type": "object",
"properties": {
"type": { "const": "payment.succeeded" },
"amount": { "type": "number", "minimum": 0 },
"currency": { "type": "string", "pattern": "^[A-Z]{3}$" },
"chargeId": { "type": "string" }
},
"required": ["type", "amount", "currency", "chargeId"],
"additionalProperties": false
},
{
"type": "object",
"properties": {
"type": { "const": "user.created" },
"userId": { "type": "string" },
"email": { "type": "string", "format": "email" }
},
"required": ["type", "userId", "email"],
"additionalProperties": false
},
{
"type": "object",
"properties": {
"type": { "const": "payment.refunded" },
"amount": { "type": "number", "minimum": 0 },
"chargeId": { "type": "string" },
"reason": { "type": "string" }
},
"required": ["type", "amount", "chargeId"],
"additionalProperties": false
}
]
}
// Valid: { "type": "user.created", "userId": "u_1", "email": "a@b.com" }
// Invalid: { "type": "user.created", "userId": "u_1" }
// → missing required "email" — Ajv reports the error for the matching branch
// Invalid: { "type": "unknown" }
// → no branch has const "unknown" → zero branches match → oneOf failsUse additionalProperties: false on each branch to prevent data intended for one variant from accidentally satisfying another. Without it, an object could technically pass multiple branches if the const is the only distinguishing constraint.
Organizing Branches with $defs
// Cleaner: use $defs to separate variant definitions
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$defs": {
"CircleShape": {
"type": "object",
"properties": {
"type": { "const": "circle" },
"radius": { "type": "number", "exclusiveMinimum": 0 }
},
"required": ["type", "radius"],
"additionalProperties": false
},
"RectShape": {
"type": "object",
"properties": {
"type": { "const": "rect" },
"width": { "type": "number", "exclusiveMinimum": 0 },
"height": { "type": "number", "exclusiveMinimum": 0 }
},
"required": ["type", "width", "height"],
"additionalProperties": false
}
},
"oneOf": [
{ "$ref": "#/$defs/CircleShape" },
{ "$ref": "#/$defs/RectShape" }
]
}Alternative: if/then/else for Discriminated Dispatch
JSON Schema's if/then/else keywords offer a more efficient alternative to oneOf for discriminated unions. Because if checks the discriminator value first, only the matching then branch is evaluated — giving O(1) branch selection rather than sequential evaluation.
// if/then/else discriminated dispatch — more efficient than oneOf
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"type": "object",
"required": ["type"],
"properties": {
"type": { "type": "string" }
},
"if": { "properties": { "type": { "const": "circle" } }, "required": ["type"] },
"then": {
"properties": { "radius": { "type": "number", "exclusiveMinimum": 0 } },
"required": ["radius"],
"additionalProperties": false,
"properties": {
"type": { "const": "circle" },
"radius": { "type": "number", "exclusiveMinimum": 0 }
}
},
"else": {
"if": { "properties": { "type": { "const": "rect" } }, "required": ["type"] },
"then": {
"properties": {
"type": { "const": "rect" },
"width": { "type": "number", "exclusiveMinimum": 0 },
"height": { "type": "number", "exclusiveMinimum": 0 }
},
"required": ["type", "width", "height"],
"additionalProperties": false
},
"else": false
}
}The else: false at the end ensures that any input whose type does not match any branch is rejected. Without it, unknown type values would pass validation silently.
oneOf vs if/then — When to Use Each
| Concern | oneOf + const | if/then/else |
|---|---|---|
| Branch evaluation | All branches (O(n)) | Only matching branch (O(1)) |
| Readability | Flat, easy to scan | Nested, harder with many variants |
| Error messages | Generic "exactly one" on mismatch | Branch-specific errors |
| OpenAPI support | Full (with discriminator hint) | Limited — if/then not rendered well |
| Best for | APIs and schemas shared with OpenAPI | High-throughput internal validation |
See the dedicated guide on JSON Schema if/then/else for the full set of patterns, including nested conditions and cross-property dependencies.
OpenAPI 3.x Discriminator Object
OpenAPI 3.x adds a non-standard discriminator object as a sibling to oneOf or anyOf. It tells code generators (openapi-typescript, Redocly, Stoplight) which schema to deserialize to based on the discriminator property value — without requiring them to try every branch.
# OpenAPI 3.1 — discriminator with explicit mapping
components:
schemas:
WebhookEvent:
oneOf:
- $ref: '#/components/schemas/PaymentSucceeded'
- $ref: '#/components/schemas/UserCreated'
- $ref: '#/components/schemas/PaymentRefunded'
discriminator:
propertyName: type
mapping:
payment.succeeded: '#/components/schemas/PaymentSucceeded'
user.created: '#/components/schemas/UserCreated'
payment.refunded: '#/components/schemas/PaymentRefunded'
PaymentSucceeded:
type: object
properties:
type:
type: string
enum: [payment.succeeded]
amount:
type: number
minimum: 0
currency:
type: string
chargeId:
type: string
required: [type, amount, currency, chargeId]
UserCreated:
type: object
properties:
type:
type: string
enum: [user.created]
userId:
type: string
email:
type: string
format: email
required: [type, userId, email]The allOf + $ref Pattern
When variants share common base fields (like id, createdAt, type), use allOf with a $ref to the base schema plus the discriminator:
components:
schemas:
BaseEvent:
type: object
properties:
type: { type: string }
timestamp: { type: string, format: date-time }
eventId: { type: string }
required: [type, timestamp, eventId]
PaymentSucceeded:
allOf:
- $ref: '#/components/schemas/BaseEvent'
- type: object
properties:
type:
type: string
enum: [payment.succeeded]
amount:
type: number
minimum: 0
chargeId:
type: string
required: [amount, chargeId]
WebhookEvent:
oneOf:
- $ref: '#/components/schemas/PaymentSucceeded'
- $ref: '#/components/schemas/UserCreated'
discriminator:
propertyName: typeThe mapping field is optional when the discriminator values match the schema names. It is required when the values use dots (like payment.succeeded) or when the schema names differ from the values. The discriminator object is an OpenAPI extension — it has no effect in standard JSON Schema validators.
Learn more in the companion guide on JSON Schema and OpenAPI.
Ajv: Validating Discriminated Unions
Ajv evaluates oneOf branches sequentially. When you use const on the discriminator property, validation is fast because at most one branch has a matching const — the others fail their const check immediately. Error messages are also much clearer because Ajv can identify which branch was intended.
import Ajv from 'ajv'
const ajv = new Ajv({ allErrors: true })
const schema = {
oneOf: [
{
type: 'object',
properties: {
type: { const: 'circle' },
radius: { type: 'number', exclusiveMinimum: 0 }
},
required: ['type', 'radius'],
additionalProperties: false
},
{
type: 'object',
properties: {
type: { const: 'rect' },
width: { type: 'number', exclusiveMinimum: 0 },
height: { type: 'number', exclusiveMinimum: 0 }
},
required: ['type', 'width', 'height'],
additionalProperties: false
}
]
}
const validate = ajv.compile(schema)
// Valid
console.log(validate({ type: 'circle', radius: 5 })) // true
// Invalid — missing radius
validate({ type: 'circle' })
console.log(validate.errors)
// [{ instancePath: '', keyword: 'oneOf',
// message: 'must match exactly one schema in oneOf',
// params: { passingSchemas: null } }]
// Invalid — unknown type
validate({ type: 'triangle', sides: 3 })
// → same oneOf error; no branch has const "triangle"Better Error Messages with ajv-errors
import Ajv from 'ajv'
import ajvErrors from 'ajv-errors'
import addFormats from 'ajv-formats'
const ajv = new Ajv({ allErrors: true, jsonPointers: true })
ajvErrors(ajv)
addFormats(ajv)
const webhookSchema = {
oneOf: [
{
type: 'object',
properties: {
type: { const: 'payment.succeeded' },
amount: { type: 'number', minimum: 0 },
chargeId: { type: 'string' }
},
required: ['type', 'amount', 'chargeId'],
additionalProperties: false
},
{
type: 'object',
properties: {
type: { const: 'user.created' },
userId: { type: 'string' },
email: { type: 'string', format: 'email' }
},
required: ['type', 'userId', 'email'],
additionalProperties: false
}
],
errorMessage: {
oneOf: 'Event must be a payment.succeeded or user.created object with required fields'
}
}
const validate = ajv.compile(webhookSchema)
validate({ type: 'unknown' })
// → [{ message: 'Event must be a payment.succeeded or user.created object with required fields' }]Performance Tip: Prefer if/then Over oneOf in Ajv
For schemas with many variants (5+), replace oneOf with nested if/then/else. Ajv evaluates the if condition on the discriminator field and jumps directly to the matching then branch — O(1) instead of O(n). The trade-off is more verbose schema code, but the validation performance benefit is significant at scale.
Read the Ajv JSON Schema validator guide for full configuration, format plugins, and error handling patterns.
Zod discriminatedUnion(): O(1) Validation
Zod's z.discriminatedUnion() is the TypeScript-first equivalent of JSON Schema's discriminator pattern. It builds an internal Map keyed by the discriminator literal values at compile time, so runtime validation is O(1) — it reads the discriminator field, looks up the schema, and validates only that schema.
import { z } from 'zod'
// Define variants — each must have a z.literal() on the discriminator field
const CircleSchema = z.object({
type: z.literal('circle'),
radius: z.number().positive(),
})
const RectSchema = z.object({
type: z.literal('rect'),
width: z.number().positive(),
height: z.number().positive(),
})
const PolygonSchema = z.object({
type: z.literal('polygon'),
vertices: z.array(z.tuple([z.number(), z.number()])).min(3),
})
// discriminatedUnion — first arg is the discriminator key
const ShapeSchema = z.discriminatedUnion('type', [
CircleSchema,
RectSchema,
PolygonSchema,
])
// TypeScript type is automatically inferred as a proper discriminated union
type Shape = z.infer<typeof ShapeSchema>
// type Shape =
// | { type: "circle"; radius: number }
// | { type: "rect"; width: number; height: number }
// | { type: "polygon"; vertices: [number, number][] }
// Validate with safeParse (never throws)
const result = ShapeSchema.safeParse({ type: 'circle', radius: -1 })
if (!result.success) {
console.log(result.error.format())
// → { type: ..., radius: { _errors: ['Number must be greater than 0'] } }
}
// Unknown discriminator value
const bad = ShapeSchema.safeParse({ type: 'triangle', sides: 3 })
// → { success: false, error: ZodError: Invalid discriminator value.
// Expected 'circle' | 'rect' | 'polygon' }z.union() vs z.discriminatedUnion() — Performance
import { z } from 'zod'
// z.union() — tries each schema in sequence: O(n)
const SlowUnion = z.union([
z.object({ type: z.literal('a'), dataA: z.string() }),
z.object({ type: z.literal('b'), dataB: z.number() }),
// ... 8 more variants
])
// z.discriminatedUnion() — O(1) map lookup
const FastUnion = z.discriminatedUnion('type', [
z.object({ type: z.literal('a'), dataA: z.string() }),
z.object({ type: z.literal('b'), dataB: z.number() }),
// ... 8 more variants — no performance penalty
])
// Benchmark (10,000 iterations, 10 variants):
// z.union(): ~12ms
// z.discriminatedUnion(): ~0.1ms (~100x faster)The Zod schema validation guide covers safeParse, error formatting, schema composition, and integration with React Hook Form and tRPC.
TypeScript Discriminated Union Types
TypeScript has first-class support for discriminated unions via type narrowing. When a type field is typed as a string literal, TypeScript narrows the object type inside if blocks and switch statements automatically — no casting required.
// Define the discriminated union type
type Shape =
| { type: 'circle'; radius: number }
| { type: 'rect'; width: number; height: number }
| { type: 'polygon'; vertices: [number, number][] }
// TypeScript narrows automatically in switch
function area(shape: Shape): number {
switch (shape.type) {
case 'circle':
return Math.PI * shape.radius ** 2 // shape is { type: 'circle'; radius: number }
case 'rect':
return shape.width * shape.height // shape is { type: 'rect'; ... }
case 'polygon':
return shoelaceFormula(shape.vertices) // shape is { type: 'polygon'; ... }
default:
// Exhaustiveness check — TypeScript errors if a case is missing
const _exhaustive: never = shape
throw new Error(`Unhandled shape: ${JSON.stringify(_exhaustive)}`)
}
}
function shoelaceFormula(vertices: [number, number][]): number {
// Shoelace formula for polygon area
let area = 0
for (let i = 0; i < vertices.length; i++) {
const [x1, y1] = vertices[i]
const [x2, y2] = vertices[(i + 1) % vertices.length]
area += x1 * y2 - x2 * y1
}
return Math.abs(area) / 2
}Deriving Types from Zod Schemas
import { z } from 'zod'
const WebhookEventSchema = z.discriminatedUnion('type', [
z.object({
type: z.literal('payment.succeeded'),
amount: z.number().nonnegative(),
currency: z.string().length(3),
chargeId: z.string(),
}),
z.object({
type: z.literal('user.created'),
userId: z.string(),
email: z.string().email(),
}),
z.object({
type: z.literal('payment.refunded'),
amount: z.number().nonnegative(),
chargeId: z.string(),
reason: z.string().optional(),
}),
])
// z.infer produces a proper TypeScript discriminated union type
type WebhookEvent = z.infer<typeof WebhookEventSchema>
// Use in a handler — TypeScript narrows correctly
function handleWebhook(event: WebhookEvent): void {
switch (event.type) {
case 'payment.succeeded':
console.log(`Charge ${event.chargeId}: ${event.amount} ${event.currency}`)
break
case 'user.created':
console.log(`New user ${event.userId} <${event.email}>`)
break
case 'payment.refunded':
console.log(`Refund for ${event.chargeId}: ${event.amount}`)
break
default:
const _: never = event // TypeScript errors if a case is unhandled
}
}The const _: never = event pattern is the TypeScript standard for exhaustiveness checking. If you add a new variant to the Zod schema and forget to handle it in the switch, TypeScript will report a type error at the never assignment — catching the bug at compile time rather than at runtime.
For more on type-safe JSON Schema and TypeScript patterns, see the JSON Schema patterns guide.
Definitions
- Discriminated union
- A set of mutually exclusive object schemas unified by a shared tag field (the discriminator property) whose literal value uniquely identifies which variant applies. Enables O(1) branch selection and precise error messages.
- Discriminator property
- The shared field in a discriminated union whose value uniquely identifies the variant. Commonly named
type,kind, orevent. Each variant constrains the discriminator to a unique literal withconstin JSON Schema orz.literal()in Zod. oneOf- A JSON Schema keyword requiring the instance to be valid against exactly one of the listed subschemas. All subschemas are always evaluated, making it O(n). Used for discriminated unions when combined with
conston the discriminator property. if/then/else- JSON Schema keywords for conditional validation. When used with a discriminator
constin theifcondition, only the matchingthenbranch is validated — achieving O(1) discriminated dispatch more efficiently thanoneOf. - OpenAPI discriminator
- A non-standard OpenAPI 3.x extension object (sibling to
oneOf) withpropertyNameand optionalmappingfields. Tells code generators and documentation tools which schema to use based on the discriminator value. Not a validation rule — most JSON Schema validators, including Ajv, ignore it. - Zod discriminatedUnion
z.discriminatedUnion(key, schemas)— a Zod method that builds a lookup map from literal discriminator values to variant schemas at definition time. Validation is O(1): read the discriminator, look up the schema, validate. Produces narrowed TypeScript types viaz.infer.- Type narrowing
- A TypeScript feature where the compiler refines the type of a variable inside a conditional block based on a type guard or discriminator check. In a
switch (event.type)block, TypeScript automatically narrowseventto the specific variant type in each case branch.
FAQ
What is a discriminated union in JSON Schema?
A discriminated union is a pattern where a shared literal field — typically type or kind — uniquely identifies which schema variant should validate a JSON object. In JSON Schema, each branch in a oneOf array adds a const on the discriminator property, ensuring only one branch can match any given input. This makes validation unambiguous and error messages specific.
How do I implement a discriminator in JSON Schema without OpenAPI?
Use oneOf with a const constraint on the discriminator property in each branch: {"oneOf": [{"properties": {"type": {"const": "circle"}, "radius": {"type": "number"}}, "required": ["type", "radius"]}, ...]}. JSON Schema 2020-12 has no built-in discriminator keyword — the const pattern on oneOf branches is the standard approach. Alternatively, use nested if/then/else for better performance with many variants.
Does Ajv support the OpenAPI discriminator keyword?
No. Ajv does not support the OpenAPI discriminator object natively — it is an OpenAPI extension to JSON Schema and is ignored by standard Ajv validation. To use discriminator semantics with Ajv, implement it manually with oneOf + const on the discriminator property. The ajv-openapi plugin adds limited OpenAPI discriminator support if needed.
What is the difference between oneOf and discriminatedUnion in Zod?
z.union() tries every branch sequentially — O(n). z.discriminatedUnion("type", [...]) builds a lookup map at definition time and validates in O(1): it reads the discriminator field, looks up the schema, and validates only that schema. For 10+ variants, discriminatedUnionis roughly 100× faster. It also produces better error messages by identifying the intended branch from the discriminator value.
How do I add a discriminator to an OpenAPI 3.x schema?
Add a discriminator object at the same level as your oneOf: {"oneOf": [...], "discriminator": {"propertyName": "type", "mapping": {"circle": "#/components/schemas/Circle"}}}. Set propertyName to the tag field name. The mapping is optional when discriminator values match schema names, but required for values containing dots (like payment.succeeded). This is a hint for code generators — it does not affect JSON Schema validation behavior.
How do I validate that a JSON object matches exactly one of several shapes?
Use JSON Schema oneOf with a const on a shared discriminator property in each branch. The const values must be unique across branches. Add additionalProperties: false to each branch to prevent unexpected field combinations. With a discriminator const, exactly one branch can ever match, satisfying oneOf's requirement of exactly one valid branch.
What is the performance difference between oneOf and discriminatedUnion?
JSON Schema oneOf in Ajv evaluates every branch sequentially — O(n) where n is the number of variants. There is no short-circuiting. Zod's discriminatedUnion() does an O(1) map lookup then validates only the matching branch. For n=10 variants, discriminatedUnion is roughly 100× faster than union(). In Ajv, you can approximate O(1) by using if/then/else chains on the discriminator instead of oneOf.
How do I narrow TypeScript types using a JSON discriminator field?
Define a TypeScript discriminated union with a string literal type field: type Shape = { type: "circle"; radius: number } | { type: "rect"; width: number; height: number }. TypeScript narrows automatically inside switch (shape.type) blocks. Add a default: const _: never = shape branch for exhaustiveness checking. With Zod, use z.discriminatedUnion() and z.infer<typeof schema> to derive the same narrowed union type automatically.
Further reading and primary sources
- JSON Schema: oneOf, anyOf, allOf — Official JSON Schema reference for composition keywords including oneOf
- OpenAPI 3.x Discriminator Object — OpenAPI specification for the discriminator object with propertyName and mapping
- Zod discriminatedUnion — Zod documentation for discriminatedUnion() with examples and TypeScript inference
- Ajv: oneOf Validation — Ajv documentation for oneOf validation behavior and error messages