Skip to content

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);
Output
Press Execute to run this example against the real library.

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');
Output
Press Execute to run this example against the real library.

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);
Output
Press Execute to run this example against the real library.

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'
]);
Output
Press Execute to run this example against the real library.

Comparison

ts
const PaymentSchema = Compose.discriminatedUnion(
  'method',
  [CreditCardPaymentSchema, InvoicePaymentSchema] as const,
  'https://bookstore.example/Payment',
);
// discriminator hint emitted; type is CreditCardPayment | InvoicePayment
ts
const 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>;
ts
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>;
ts
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.
ts
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 OpenAPI
ts
const PaymentSchema = {
  $id: 'https://bookstore.example/Payment',
  discriminator: { propertyName: 'method' },
  oneOf: [CreditCardPaymentSchema, InvoicePaymentSchema],
};
// Requires { discriminator: true } in Ajv options
py
from 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')]
ts
// Limitation: feature not directly supported in Yup. See /comparisons for the matrix.
ts
// Limitation: feature not directly supported in Joi. See /comparisons for the matrix.
ts
// Limitation: feature not directly supported in Effect Schema. See /comparisons for the matrix.
ts
// Limitation: feature not directly supported in ArkType. See /comparisons for the matrix.
ts
// 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');
Output
Press Execute to run this example against the real library.

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');
Output
Press Execute to run this example against the real library.

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.

  • intersection - combine schemas that must ALL be satisfied
  • extend - add properties without creating a union
  • Type Inference - how the TypeScript union type is inferred

See also

Released under the MIT License.