Compose.discriminatedUnion and Compose.narrow
Validation modes: Validation modes reference
Compose.discriminatedUnion {#compose-discriminatedunion} Compile-time + Runtime
Declaration. Creates a oneOf schema with a discriminator hint. The discriminator indicates which property uniquely identifies the variant. TypeScript infers the union of all variant types. The $id is set to newId. Each variant schema should have the discriminator property with a const value.
Use this when you have a set of mutually exclusive shapes identified by a single discriminator property - for example, payment methods (credit_card / invoice / gift_card), event types (placed / shipped / cancelled), or document types (book / periodical / ebook). The discriminator hint improves validator performance and is recognized by OpenAPI tooling.
Don't use this when you need all variants to share properties without a discriminator (use intersection). Don't use it when variants don't have a constant distinguishing property (use a plain anyOf schema literal instead).
Examples
Example 1: Payment method union
/**
* Compose.discriminatedUnion — Example 1: Book print status union
*
* Demonstrates oneOf with a discriminator. Book print-status variants
* use the canonical `InPrintBookSchema` / `OutOfPrintBookSchema` from
* the bookstore. The discriminator is `printStatus` ('inPrint' vs
* 'outOfPrint'). Inputs are the canonical rare-book fixture (Bastian's
* 1979 Thienemann first edition of *Die unendliche Geschichte*) and a
* sibling in-print Michael Ende title (Momo, 9783522115056).
*/
import { Compose } from '../../../src/index.js';
import type { InferType } from '../../../src/types/index.js';
import {
aboxFixtures, createBookstoreDocRegistry
} from '../bookstore/index.js';
// createBookstoreDocRegistry seeds a permissive copy of the bookstore — docs examples extend
// it with ad-hoc demo schemas; strict-graph checking is intentionally off here.
const jt = createBookstoreDocRegistry();
// Local variant schemas with explicit const discriminator so
// ValidateDiscriminatedVariantsType<..., 'printStatus'> is satisfied.
const InPrintVariantSchema = {
'$id': 'https://bookstore.example/InPrintVariant',
'properties': {
'authors': {
'items': { 'type': 'string' },
'minItems': 1,
'type': 'array'
},
'inStock': { 'type': 'boolean' },
'isbn': { 'type': 'string' },
'price': { 'type': 'object' },
'printStatus': { 'const': 'inPrint' },
'title': { 'type': 'string' }
},
'required': [
'isbn',
'title',
'authors',
'price',
'printStatus',
'inStock'
],
'type': 'object'
} as const;
const OutOfPrintVariantSchema = {
'$id': 'https://bookstore.example/OutOfPrintVariant',
'properties': {
'authors': {
'items': { 'type': 'string' },
'minItems': 1,
'type': 'array'
},
'inStock': { 'type': 'boolean' },
'isbn': { 'type': 'string' },
'price': { 'type': 'object' },
'printStatus': { 'const': 'outOfPrint' },
'title': { 'type': 'string' }
},
'required': [
'isbn',
'title',
'authors',
'price',
'printStatus'
],
'type': 'object'
} as const;
const BookStatusSchema = Compose.discriminatedUnion(
'printStatus',
[
InPrintVariantSchema,
OutOfPrintVariantSchema
] as const,
'https://bookstore.example/BookStatus'
);
type BookStatus = InferType<typeof BookStatusSchema>;
const jt2 = jt.set(BookStatusSchema);
// OutOfPrint variant — Bastian's rare 1979 Thienemann hardcover.
const outOfPrintErrs = jt2.validate(BookStatusSchema.$id, aboxFixtures.rareBook);
console.assert(outOfPrintErrs.length === 0);
// InPrint variant — Michael Ende's Momo (Thienemann Verlag, 1973), still in print.
const inPrintData = {
'authors': ['Michael Ende'],
'inStock': true,
'isbn': '9783522115056',
'price': {
'amount': 16.99,
'currency': 'EUR'
},
'printStatus': 'inPrint',
'title': 'Momo'
} as const;
const inPrintErrs = jt2.validate(BookStatusSchema.$id, inPrintData);
console.assert(inPrintErrs.length === 0);
// Compile-time discriminator narrowing — `BookStatus` is the discriminated
// union of `InPrintVariant | OutOfPrintVariant`. instantiate returns the
// branded union value, narrowable on the literal `printStatus` discriminator.
const rare: BookStatus = jt2.instantiate(BookStatusSchema.$id, aboxFixtures.rareBook);
const description = rare.printStatus === 'inPrint'
? `In print: ${rare.title}`
: `Rare: ${rare.title}`;
console.assert(description.startsWith('Rare: Die unendliche Geschichte'));
console.log('Discriminated union narrowed on printStatus:', description);
console.log('OutOfPrint variant validates:', outOfPrintErrs.length === 0, '| InPrint variant validates:', inPrintErrs.length === 0);
Example 2: Validate each variant
/**
* Compose.discriminatedUnion — Example 2: Validate each variant
*
* `InPrintBookSchema` and `OutOfPrintBookSchema` are the canonical
* variants of Book.printStatus. The discriminator (printStatus)
* routes validation to the correct branch.
*/
import { Compose } from '../../../src/index.js';
import {
aboxFixtures, createBookstoreDocRegistry
} from '../bookstore/index.js';
// createBookstoreDocRegistry seeds a permissive copy of the bookstore — docs examples extend
// it with ad-hoc demo schemas; strict-graph checking is intentionally off here.
const jt = createBookstoreDocRegistry();
// Local variant schemas with explicit const discriminator so
// ValidateDiscriminatedVariantsType<..., 'printStatus'> is satisfied.
const InPrintVariantSchema = {
'$id': 'https://bookstore.example/InPrintVariant2',
'properties': {
'authors': {
'items': { 'type': 'string' },
'minItems': 1,
'type': 'array'
},
'inStock': { 'type': 'boolean' },
'isbn': { 'type': 'string' },
'price': { 'type': 'object' },
'printStatus': { 'const': 'inPrint' },
'title': { 'type': 'string' }
},
'required': [
'isbn',
'title',
'authors',
'price',
'printStatus',
'inStock'
],
'type': 'object'
} as const;
const OutOfPrintVariantSchema = {
'$id': 'https://bookstore.example/OutOfPrintVariant2',
'properties': {
'authors': {
'items': { 'type': 'string' },
'minItems': 1,
'type': 'array'
},
'inStock': { 'type': 'boolean' },
'isbn': { 'type': 'string' },
'price': { 'type': 'object' },
'printStatus': { 'const': 'outOfPrint' },
'title': { 'type': 'string' }
},
'required': [
'isbn',
'title',
'authors',
'price',
'printStatus'
],
'type': 'object'
} as const;
const BookStatusVariantSchema = Compose.discriminatedUnion(
'printStatus',
[
InPrintVariantSchema,
OutOfPrintVariantSchema
] as const,
'https://bookstore.example/BookStatusVariant'
);
const jt2 = jt.set(BookStatusVariantSchema);
// Out-of-print variant — Bastian's rare 1979 Thienemann fixture.
const outOfPrint = jt2.validate(BookStatusVariantSchema.$id, aboxFixtures.rareBook);
console.assert(outOfPrint.ok);
console.log('OutOfPrint variant validates:', outOfPrint.ok);
// In-print variant — Momo (still printed by Thienemann).
const inPrint = jt2.validate(BookStatusVariantSchema.$id, {
'authors': ['Michael Ende'],
'inStock': true,
'isbn': '9783522115056',
'price': {
'amount': 16.99,
'currency': 'EUR'
},
'printStatus': 'inPrint',
'title': 'Momo'
});
console.assert(inPrint.ok);
console.log('InPrint variant validates:', inPrint.ok, '| discriminator:', 'printStatus');
Example 3: Order with a discriminated payment field (builds on extend)
Extend OrderSchema with a payment field typed as the union, register the composite, then validate against it.
/**
* Compose.discriminatedUnion — Example 3: Order extended with a payment field
*
* Builds on `Compose.extend`: extend OrderSchema with a `payment`
* slot typed as a discriminated union, then validate the composite
* against a Bastian-orders-Neverending-Story payload that includes a
* credit-card payment.
*/
import { Compose } from '../../../src/index.js';
import {
aboxFixtures, createBookstoreDocRegistry,
OrderSchema
} from '../bookstore/index.js';
// createBookstoreDocRegistry seeds a permissive copy of the bookstore — docs examples extend
// it with ad-hoc demo schemas; strict-graph checking is intentionally off here.
const jt = createBookstoreDocRegistry();
const CreditCardPaymentSchema = {
'$id': 'https://bookstore.example/CreditCardPayment',
'properties': {
'cardLast4': {
'pattern': '^\\d{4}$',
'type': 'string'
},
'expiry': {
'pattern': '^\\d{2}/\\d{2}$',
'type': 'string'
},
'method': {
'const': 'credit_card',
'type': 'string'
}
},
'required': [
'method',
'cardLast4',
'expiry'
],
'type': 'object'
} as const;
const InvoicePaymentSchema = {
'$id': 'https://bookstore.example/InvoicePayment',
'properties': {
'method': {
'const': 'invoice',
'type': 'string'
},
'purchaseOrder': { 'type': 'string' }
},
'required': [
'method',
'purchaseOrder'
],
'type': 'object'
} as const;
const PaymentSchema = Compose.discriminatedUnion(
'method',
[
CreditCardPaymentSchema,
InvoicePaymentSchema
] as const,
'https://bookstore.example/Payment'
);
const OrderWithPaymentSchema = Compose.extend(
OrderSchema,
{ 'payment': { '$ref': PaymentSchema.$id } } as const,
'https://bookstore.example/OrderWithPayment'
);
const jt2 = jt
.set(CreditCardPaymentSchema)
.set(InvoicePaymentSchema)
.set(PaymentSchema)
.set(OrderWithPaymentSchema);
const result = jt2.validate(OrderWithPaymentSchema.$id, {
...aboxFixtures.order,
'payment': {
'cardLast4': '4242',
'expiry': '12/28',
'method': 'credit_card'
}
});
console.assert(result.ok);
console.log('OrderWithPayment validates credit-card payment union:', result.ok);
Discriminator argument validation Compile-time
Every variant must declare properties[prop] as const and list prop in required. Missing or non-const discriminators surface a DiscriminatorMissingType brand error at the call site - a compile error rather than a runtime surprise.
/**
* Compose.discriminatedUnion — Discriminator argument validation
*
* Every variant must declare `properties[prop]` as `const` and list
* `prop` in `required`. A well-formed variant set lets
* `Compose.discriminatedUnion` build a sound union.
*
* Variants must directly expose `properties[discriminator].const` at the
* top level for the compile-time validator to accept them. These schemas
* satisfy the contract explicitly.
*/
import { Compose } from '../../../src/index.js';
const InPrintVariantSchema = {
'$id': 'https://bookstore.example/InPrintVariant3',
'properties': {
'authors': {
'items': { 'type': 'string' },
'minItems': 1,
'type': 'array'
},
'inStock': { 'type': 'boolean' },
'isbn': { 'type': 'string' },
'price': { 'type': 'object' },
'printStatus': { 'const': 'inPrint' },
'title': { 'type': 'string' }
},
'required': [
'isbn',
'title',
'authors',
'price',
'printStatus',
'inStock'
],
'type': 'object'
} as const;
const OutOfPrintVariantSchema = {
'$id': 'https://bookstore.example/OutOfPrintVariant3',
'properties': {
'authors': {
'items': { 'type': 'string' },
'minItems': 1,
'type': 'array'
},
'isbn': { 'type': 'string' },
'price': { 'type': 'object' },
'printStatus': { 'const': 'outOfPrint' },
'title': { 'type': 'string' }
},
'required': [
'isbn',
'title',
'authors',
'price',
'printStatus'
],
'type': 'object'
} as const;
const PrintStatusUnionSchema = Compose.discriminatedUnion(
'printStatus',
[
InPrintVariantSchema,
OutOfPrintVariantSchema
] as const,
'https://bookstore.example/PrintStatusUnion'
);
const unionId: string = PrintStatusUnionSchema.$id;
console.assert(unionId.endsWith('PrintStatusUnion'));
console.log('PrintStatusUnion discriminated on printStatus:', unionId);
console.log('variants:', [
'InPrintVariant3',
'OutOfPrintVariant3'
]);
Comparison
const PaymentSchema = Compose.discriminatedUnion(
'method',
[CreditCardPaymentSchema, InvoicePaymentSchema] as const,
'https://bookstore.example/Payment',
);
// discriminator hint emitted; type is CreditCardPayment | InvoicePaymentconst PaymentSchema = z.discriminatedUnion('method', [
z.object({ method: z.literal('credit_card'), cardLast4: z.string() }),
z.object({ method: z.literal('invoice'), purchaseOrder: z.string() }),
]);
type Payment = z.infer<typeof PaymentSchema>;import * as v from 'valibot';
const PaymentSchema = v.variant('method', [
v.object({ method: v.literal('credit_card'), cardLast4: v.string() }),
v.object({ method: v.literal('invoice'), purchaseOrder: v.string() }),
]);
type Payment = v.InferOutput<typeof PaymentSchema>;import * as t from 'io-ts';
const PaymentCodec = t.union([
t.type({ method: t.literal('credit_card'), cardLast4: t.string }),
t.type({ method: t.literal('invoice'), purchaseOrder: t.string }),
]);
type Payment = t.TypeOf<typeof PaymentCodec>;
// Limitation: io-ts has no discriminator hint. t.union tries each member in
// order; tooling like OpenAPI generators cannot recover the discriminant.import { Type } from '@sinclair/typebox';
// TypeBox uses Type.Union - no built-in discriminator support:
const PaymentSchema = Type.Union([CreditCardPaymentSchema, InvoicePaymentSchema]);
// discriminator hint must be added manually for OpenAPIconst PaymentSchema = {
$id: 'https://bookstore.example/Payment',
discriminator: { propertyName: 'method' },
oneOf: [CreditCardPaymentSchema, InvoicePaymentSchema],
};
// Requires { discriminator: true } in Ajv optionsfrom typing import Annotated, Literal
from pydantic import BaseModel, Discriminator
class CreditCardPayment(BaseModel):
method: Literal['credit_card']
card_last4: str
class InvoicePayment(BaseModel):
method: Literal['invoice']
purchase_order: str
Payment = Annotated[CreditCardPayment | InvoicePayment, Discriminator('method')]// Limitation: feature not directly supported in Yup. See /comparisons for the matrix.// Limitation: feature not directly supported in Joi. See /comparisons for the matrix.// Limitation: feature not directly supported in Effect Schema. See /comparisons for the matrix.// Limitation: feature not directly supported in ArkType. See /comparisons for the matrix.// Limitation: feature not directly supported in Runtypes. See /comparisons for the matrix.Compose.narrow {#compose-narrow} Compile-time
Declaration. Type guard that narrows a discriminated union value to the variant whose discriminant property equals expected. Returns Extract<TUnion, Record<TDiscriminant, TValue>> inside the truthy branch. No runtime effect beyond the property comparison.
Use this when you have a union value and need TypeScript to narrow it to a specific variant for type-safe field access. Pairs naturally with discriminatedUnion - same discriminant property, same value.
Don't use this when your variants don't have a single discriminant property (use manual typeof / instanceof checks instead).
Examples
Example 1: Narrow a Payment to access variant-specific fields
/**
* Compose.narrow — Example 1: Narrow a Payment to a variant
*
* `Compose.narrow(value, prop, expected)` is a type guard. Inside the
* truthy branch, the value narrows to the variant whose discriminant
* property equals `expected`. No runtime effect beyond the property
* comparison.
*/
import { Compose } from '../../../src/index.js';
interface CreditCardPayment {
readonly 'cardLast4': string;
readonly 'expiry': string;
readonly 'method': 'credit_card';
}
interface InvoicePayment {
readonly 'method': 'invoice';
readonly 'purchaseOrder': string;
}
type Payment = CreditCardPayment | InvoicePayment;
function describePayment(payment: Payment): string {
if (Compose.narrow(payment, 'method', 'credit_card')) {
return `Card ending in ${payment.cardLast4}`;
}
if (Compose.narrow(payment, 'method', 'invoice')) {
return `Invoice PO#${payment.purchaseOrder}`;
}
return 'Unknown payment method';
}
const card: Payment = {
'cardLast4': '4242',
'expiry': '12/28',
'method': 'credit_card'
};
const invoice: Payment = {
'method': 'invoice',
'purchaseOrder': 'PO-001'
};
console.assert(describePayment(card) === 'Card ending in 4242');
console.assert(describePayment(invoice) === 'Invoice PO#PO-001');
Example 2: Exhaustive switch with Compose.narrow
/**
* Compose.narrow — Example 2: Exhaustive switch with narrowing
*
* Each `Compose.narrow` branch narrows the payment value to the
* matching variant so variant-specific fields are reachable without
* type assertions.
*/
import { Compose } from '../../../src/index.js';
interface CreditCardPayment {
readonly 'cardLast4': string;
readonly 'expiry': string;
readonly 'method': 'credit_card';
}
interface InvoicePayment {
readonly 'method': 'invoice';
readonly 'purchaseOrder': string;
}
type Payment = CreditCardPayment | InvoicePayment;
const processed: string[] = [];
function chargeCard(last4: string, expiry: string): void {
processed.push(`charge ${last4} ${expiry}`);
}
function createInvoice(purchaseOrder: string): void {
processed.push(`invoice ${purchaseOrder}`);
}
function processPayment(payment: Payment): void {
if (Compose.narrow(payment, 'method', 'credit_card')) {
chargeCard(payment.cardLast4, payment.expiry);
return;
}
if (Compose.narrow(payment, 'method', 'invoice')) {
createInvoice(payment.purchaseOrder);
}
}
processPayment({
'cardLast4': '4242',
'expiry': '12/28',
'method': 'credit_card'
});
processPayment({
'method': 'invoice',
'purchaseOrder': 'PO-001'
});
console.assert(processed.length === 2);
console.assert(processed[0] === 'charge 4242 12/28');
console.assert(processed[1] === 'invoice PO-001');
Comparison
narrow has no comparison table: it is a compile-time-only type guard (a boolean-narrowing function over an existing value), not a schema builder or validator. Peer libraries express the same narrowing with native TypeScript discriminant checks (value.method === 'credit_card') rather than a dedicated runtime API, so there is no schema-construction code to compare it against.
Related
intersection- combine schemas that must ALL be satisfiedextend- add properties without creating a union- Type Inference - how the TypeScript union type is inferred
See also
- Bookstore domain - where base schemas are defined
- Composition index - overview of all composition operations