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:
extendis property-merging - single parent, additions as a sibling object schema, designed around "I have a base and I want a few more fields".Compose.subClassOfis taxonomic - accepts one OR multiple parents, signals explicit subclass intent. Reach forsubClassOfwhen you want the ontology to read as taxonomy and you may need multiple supertypes; reach forextendwhen 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
});
Example 2: Extend Book with featured display info
/**
* 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
});
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);
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');
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');
Comparison
const CustomerWithDiscount = Compose.extend(
CustomerSchema,
{ discountRate: { type: 'number', default: 0 } } as const,
'https://bookstore.example/CustomerWithDiscount',
);const CustomerWithDiscount = CustomerSchema.extend({
discountRate: z.number().default(0),
});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.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.import { Type } from '@sinclair/typebox';
const CustomerWithDiscount = Type.Composite([
CustomerSchema,
Type.Object({ discountRate: Type.Number({ default: 0 }) }),
]);
// Type.Composite merges properties from multiple schemasconst CustomerWithDiscount = {
...CustomerSchema,
$id: 'https://bookstore.example/CustomerWithDiscount',
properties: {
...CustomerSchema.properties,
discountRate: { type: 'number', default: 0 },
},
};class CustomerWithDiscount(Customer):
discount_rate: float = 0.0
tier: str | None = None// 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.Related
subClassOf/disjointWith/complementOf- explicit OWL class axioms with multi-parent supportintersection- when all schemas'requiredconstraints must applypartial- make all fields optional after extensionpick- keep only a subset of propertiesSchemas- registering extended schemas before use- Graph concepts (TBox/ABox)
See also
- Bookstore domain - where
CustomerSchemaandBookSchemaare defined - Composition index - overview of all composition operations