Skip to content

Compose.extend Compile-time + Runtime

Validation modes: Validation modes reference

Declaration. Creates a new schema by emitting { $id: newId, allOf: [{ $ref: parent.$id }, additionsSchema] }. The base schema is referenced by its $id (not flattened or copied), and the additions become a sibling object schema in the allOf array. The base schema's required constraints flow through unchanged via the $ref. The $id is replaced with the new newId argument. Input schemas are never mutated. Per-key merge happens for jt:config (child wins). TypeScript infers the merged type automatically.

Use this when you need to add fields to an existing schema while inheriting its existing properties and required constraints. Classic use: adding tier-specific fields to Customer, adding audit fields to Order, adding a display badge to Book.

Don't use this when you need all constituent schemas' required constraints to apply simultaneously and the base must also be inlined rather than referenced (use intersection directly with allOf instead). Don't use it when you want to narrow properties (use pick). Don't use it if the added fields should all be optional with default-only filling (use materialize instead).

extend vs subClassOf. Both produce the same allOf + $ref wire shape and both map to rdfs:subClassOf in the OWL TBox. The split is intentional:

  • extend is property-merging - single parent, additions as a sibling object schema, designed around "I have a base and I want a few more fields".
  • Compose.subClassOf is taxonomic - accepts one OR multiple parents, signals explicit subclass intent. Reach for subClassOf when you want the ontology to read as taxonomy and you may need multiple supertypes; reach for extend when you want fields added to a base.

Examples

Example 1: Add discount tier to Customer

Building on CustomerSchema from the bookstore domain:

/**
 * Compose.extend — Example 1: CustomerWithDiscount adds discount fields
 * Demonstrates: layering fields onto the canonical Customer via Compose.extend
 *
 * The derived schema is registered onto the canonical bookstore via
 * `jt.set()`. No mini-registry: every example operates
 * against the one source of truth.
 */

import { Compose } from '../../../src/index.js';
import {
  createBookstoreDocRegistry,
  CustomerSchema
} 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 CustomerWithDiscountSchema = Compose.extend(
  CustomerSchema,
  {
    'discountRate': {
      'default': 0,
      'maximum': 1,
      'minimum': 0,
      'type': 'number'
    },
    'tier': {
      'enum': [
        'bronze',
        'silver',
        'gold'
      ],
      'type': 'string'
    }
  } as const,
  'https://bookstore.example/CustomerWithDiscount'
);

const jt2 = jt.set(CustomerWithDiscountSchema);

const coercedCustomer = jt2.instantiate(CustomerWithDiscountSchema.$id, {
  'customerId': 'c1a2b3d4-e5f6-7890-abcd-ef1234567890',
  'discountRate': 0.15,
  'email': 'bastian.bux@bookstore.example',
  'name': 'Bastian Balthazar Bux',
  'tier': 'silver'
}) as Record<string, unknown>;

console.assert(coercedCustomer.discountRate === 0.15);
console.assert(coercedCustomer.tier === 'silver');
console.assert(coercedCustomer.name === 'Bastian Balthazar Bux');
console.log('CustomerWithDiscount:', {
  'discountRate': coercedCustomer.discountRate,
  'name': coercedCustomer.name,
  'tier': coercedCustomer.tier
});
Output
Press Execute to run this example against the real library.
/**
 * Compose.extend — Example 2: Featured book with display info
 *
 * Layers two display-only fields onto BookSchema. Inherited
 * properties (isbn, title, authors, price, printStatus) flow through
 * the `$ref` to Book, and the additional `badge` / `position` fields
 * sit alongside.
 */

import { Compose } from '../../../src/index.js';
import {
  BookSchema,
  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();

const FeaturedBookSchema = Compose.extend(
  BookSchema,
  {
    'badge': {
      'enum': [
        'bestseller',
        'new',
        'staff-pick'
      ],
      'type': 'string'
    },
    'position': {
      'minimum': 1,
      'type': 'integer'
    }
  } as const,
  'https://bookstore.example/FeaturedBook'
);

const jt2 = jt.set(FeaturedBookSchema);

const featured = jt2.instantiate(FeaturedBookSchema.$id, {
  'authors': ['Michael Ende'],
  'badge': 'bestseller',
  'inStock': true,
  'isbn': '9783522128001',
  'position': 1,
  'price': {
    'amount': 14.99,
    'currency': 'EUR'
  },
  'printStatus': 'outOfPrint',
  'title': 'Die unendliche Geschichte'
}) as Record<string, unknown>;

console.assert(featured.badge === 'bestseller');
console.assert(featured.isbn === '9783522128001');
console.assert(featured.position === 1);
console.log('FeaturedBook:', {
  'badge': featured.badge,
  'position': featured.position,
  'title': featured.title
});
Output
Press Execute to run this example against the real library.

Example 3: Tighten a property in the additions side of the allOf

Compose.extend does not flatten properties over the base. The additions appear as a second allOf entry, so a property declared in the additions becomes an additional constraint that must hold alongside the base's constraint. Both must be satisfied, which is how allOf works in JSON Schema. Use this when you want to add a stricter constraint on top of the base.

/**
 * Compose.extend — Example 3: Tighten a property via additions
 *
 * `extend` does not flatten properties over the base. The additions
 * appear as a second `allOf` entry, so a constraint declared in the
 * additions must hold alongside the base's constraint. Both must
 * satisfy — classic JSON Schema `allOf`.
 */

import { Compose } from '../../../src/index.js';
import {
  BookSchema,
  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();

const PremiumBookSchema = Compose.extend(
  BookSchema,
  {
    'price': {
      'minimum': 25,
      'type': 'object'
    }
  } as const,
  'https://bookstore.example/PremiumBook'
);

const jt2 = jt.set(PremiumBookSchema);

// A book priced at 14.99 fails the additional minimum: 25 constraint.
const cheap = jt2.validate(PremiumBookSchema.$id, {
  'authors': ['Michael Ende'],
  'inStock': true,
  'isbn': '9783522128001',
  'price': {
    'amount': 14.99,
    'currency': 'EUR'
  },
  'printStatus': 'outOfPrint',
  'title': 'Die unendliche Geschichte'
});

console.log('PremiumBook rejects price < 25:', !cheap.ok, cheap.length, 'error(s)');
const premiumId: string = PremiumBookSchema.$id;

console.assert(premiumId.endsWith('/PremiumBook'));
console.log('PremiumBook schema id:', premiumId);
Output
Press Execute to run this example against the real library.

Bad examples - what NOT to do

Anti-pattern 1: Using extend when you need required on the new fields

/**
 * Compose.extend — Anti-pattern 1: New fields are NOT required
 *
 * `extend` inherits `required` from the base. Fields added in the
 * additions object stay optional unless they appear in the base's
 * required array. Use `Compose.intersection` if the added schema
 * needs its own required constraint to apply alongside the base.
 */

import { Compose } from '../../../src/index.js';
import {
  createBookstoreDocRegistry,
  CustomerSchema
} 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();

// ✓ Do this — intersection layers a second required array onto the base.
const WithRequiredTierSchema = {
  '$id': 'https://bookstore.example/CustomerTierRequirement',
  'properties': { 'tier': { 'type': 'string' } },
  'required': ['tier'],
  'type': 'object'
} as const;

const CustomerWithRequiredTierSchema = Compose.intersection(
  [
    CustomerSchema,
    WithRequiredTierSchema
  ] as const,
  'https://bookstore.example/CustomerWithRequiredTier'
);

const jt2 = jt.set(WithRequiredTierSchema).set(CustomerWithRequiredTierSchema);

// Without tier, validation fails — the intersection requires it.
const missingTier = jt2.validate(CustomerWithRequiredTierSchema.$id, {
  'addresses': [],
  'email': 'bastian.bux@bookstore.example',
  'id': 'c1a2b3d4-e5f6-7890-abcd-ef1234567890',
  'name': 'Bastian Balthazar Bux'
});

console.assert(!missingTier.ok);
console.log('CustomerWithRequiredTier rejects missing tier:', !missingTier.ok, '| intersection enforces required on additions');
Output
Press Execute to run this example against the real library.

Anti-pattern 2: Chaining extend to build a history of derivations without registering intermediates

/**
 * Compose.extend — Anti-pattern 2: Using a derived schema before registering
 *
 * `Compose.extend` returns a new schema object; you can chain further
 * extensions off it. But every derived schema you call `instantiate`
 * or `validate` against must first be registered onto the bookstore
 * via `jt.set()`.
 */

import { Compose } from '../../../src/index.js';
import {
  createBookstoreDocRegistry,
  CustomerSchema
} 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 ASchema = Compose.extend(
  CustomerSchema,
  { 'memberSince': { 'type': 'string' } } as const,
  'https://bookstore.example/CustomerA'
);

const BSchema = Compose.extend(
  ASchema,
  { 'pointsBalance': { 'type': 'number' } } as const,
  'https://bookstore.example/CustomerB'
);

// ✓ Register before use — only then is the derived schema reachable.
const jt2 = jt.set(ASchema).set(BSchema);

const result = jt2.validate(BSchema.$id, {
  'addresses': [],
  'customerId': 'c1a2b3d4-e5f6-7890-abcd-ef1234567890',
  'email': 'bastian.bux@bookstore.example',
  'memberSince': '2019-04-01',
  'name': 'Bastian Balthazar Bux',
  'pointsBalance': 240
});

console.assert(result.ok);
console.log('CustomerB validates with inherited + added fields:', result.ok, '| schema chain: Customer -> CustomerA -> CustomerB');
Output
Press Execute to run this example against the real library.

Comparison

ts
const CustomerWithDiscount = Compose.extend(
  CustomerSchema,
  { discountRate: { type: 'number', default: 0 } } as const,
  'https://bookstore.example/CustomerWithDiscount',
);
ts
const CustomerWithDiscount = CustomerSchema.extend({
  discountRate: z.number().default(0),
});
ts
import * as v from 'valibot';
const CustomerWithDiscount = v.intersect([
  CustomerSchema,
  v.object({ discountRate: v.optional(v.number(), 0) }),
]);
// Limitation: Valibot has no inheritance/allOf model; intersect composes
// constraints structurally without preserving a $ref to the base.
ts
import * as t from 'io-ts';
const CustomerWithDiscount = t.intersection([
  CustomerCodec,
  t.partial({ discountRate: t.number }),
]);
// Limitation: io-ts has no inheritance/allOf model; intersection composes
// codecs structurally and there is no native default-value mechanism.
ts
import { Type } from '@sinclair/typebox';
const CustomerWithDiscount = Type.Composite([
  CustomerSchema,
  Type.Object({ discountRate: Type.Number({ default: 0 }) }),
]);
// Type.Composite merges properties from multiple schemas
ts
const CustomerWithDiscount = {
  ...CustomerSchema,
  $id: 'https://bookstore.example/CustomerWithDiscount',
  properties: {
    ...CustomerSchema.properties,
    discountRate: { type: 'number', default: 0 },
  },
};
py
class CustomerWithDiscount(Customer):
    discount_rate: float = 0.0
    tier: str | None = None
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.

See also

Released under the MIT License.