Sub-schema patterns
Runnable code patterns that demonstrate how registered sub-schemas behave under each of the four core operations: validation, instantiation (defaults and coercion), TBox emission, composition through $refs, and self-referential cycles. The declarative summary lives at Sub-schemas and $ref composition.
All examples use the bookstore domain.
Validation reaches into $refs
/**
* Sub-schema patterns — Example 1: $ref-reach behaviours
*
* Demonstrates how validation, instantiation, and serialization reach
* through `$ref`-composed sub-schemas in the canonical bookstore. Every
* call goes through `bookstoreEntities` so the runtime walks the same
* graph the docs describe.
*
* Threaded narrative: Bastian Balthazar Bux's customer record, composed
* of CustomerId + Email + PersonName + Address primitives via `$ref`.
*/
import {
aboxFixtures, bookstoreEntities, CustomerSchema
} from '../bookstore/index.js';
// 1. Validation reaches into $ref'd primitives.
const okErrs = bookstoreEntities.validate(CustomerSchema.$id, aboxFixtures.customer);
console.assert(okErrs.length === 0);
// 0 — valid fixture passes
// A malformed email surfaces a `format` error at the parent slot path.
console.log('validate ok errors:', okErrs.length);
const badErrs = [...bookstoreEntities.validate(CustomerSchema.$id, {
...aboxFixtures.customer,
'email': 'not-an-email'
})];
const formatErr = badErrs.find((err) => {
return err.keyword === 'format' && err.path === '/email';
});
console.assert(formatErr !== undefined);
// /email — reached through $ref
// 2. Defaults from $ref'd primitives flow through instantiate.
console.log('format error path:', formatErr?.path);
const created = bookstoreEntities.instantiate(CustomerSchema.$id, {
'customerId': aboxFixtures.customer.customerId,
'email': aboxFixtures.customer.email,
'name': aboxFixtures.customer.name
// addresses omitted — default `[]` from CustomerSchema
});
console.assert(Array.isArray(created.addresses) && created.addresses.length === 0);
// [] — default filled via $ref
// 3. Dump round-trips through the same $ref graph. instantiate first to
console.log('addresses default:', created.addresses);
// obtain the branded value dump's typed overload expects.
const customer = bookstoreEntities.instantiate(CustomerSchema.$id, aboxFixtures.customer);
const wire = bookstoreEntities.dump(CustomerSchema.$id, customer);
console.assert('email' in wire);
// round-tripped through $ref graph
console.log('dump email:', (wire as { 'email': string }).email);
The validator follows the $ref to EmailSchema and applies its format: 'email' constraint. The error path points at the parent's slot (/email), not at the referenced schema. Callers see one validation surface per request.
Defaults from sub-schemas flow through instantiate
/**
* Sub-schema patterns — defaults flow through $ref via instantiate
*
* Defaults declared inside a referenced schema apply when the parent's
* value reaches that slot. The registry walks the `$ref` graph, so
* transitive defaults all resolve in a single pass.
*
* Demonstrated by registering a small Preferences sub-schema and
* referencing it from a Profile schema; both are registered against
* `` so the canonical registry's `enableDefaults`
* behaviour drives the example.
*/
import {
createBookstoreDocRegistry,
CustomerIdSchema
} 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 PreferencesSchema = {
'$id': 'https://bookstore.example/Preferences',
'properties': {
'locale': {
'default': 'en-US',
'type': 'string'
},
'notifications': {
'default': true,
'type': 'boolean'
}
},
'type': 'object'
} as const;
const ProfileSchema = {
'$id': 'https://bookstore.example/Profile',
'properties': {
'customerId': { '$ref': CustomerIdSchema.$id },
'preferences': { '$ref': PreferencesSchema.$id }
},
'required': ['customerId'],
'type': 'object'
} as const;
const jt2 = jt.set(PreferencesSchema).set(ProfileSchema);
const profile = jt2.instantiate(ProfileSchema.$id, {
'customerId': 'c1a2b3d4-e5f6-7890-abcd-ef1234567890',
'preferences': {}
}, { 'enableDefaults': true }) as {
readonly 'preferences': {
readonly 'locale': string;
readonly 'notifications': boolean;
};
};
console.assert(profile.preferences.locale === 'en-US');
console.assert(profile.preferences.notifications);
// 'en-US' — filled via $ref
console.log('locale default:', profile.preferences.locale);
// true — filled via $ref
console.log('notifications default:', profile.preferences.notifications);
Defaults declared inside a referenced schema apply when the parent's value reaches that slot. The registry walks the $ref graph, so transitive defaults (a $ref to a schema that itself has a $ref) all resolve in a single pass.
Coercion respects sub-schema constraints and Transforms
/**
* Sub-schema patterns — coercion respects sub-schema constraints
*
* Format constraints on a referenced schema apply on the parent's
* slot. `Transform` decoders registered against the sub-schema's
* `$id` run on the parent's value too — one decoder, every reference.
*
* Demonstrated against the canonical `Iso8601Schema` (referenced by
* `OrderSchema.placedAt`); the order fixture validates clean, and a
* malformed value surfaces a `format` error at the `/placedAt` slot.
*/
import {
aboxFixtures, bookstoreEntities, OrderSchema
} from '../bookstore/index.js';
const okErrs = bookstoreEntities.validate(OrderSchema.$id, aboxFixtures.order);
console.assert(okErrs.length === 0);
// 0 — ISO 8601 format satisfied via $ref
console.log('valid order errors:', okErrs.length);
const badErrs = [...bookstoreEntities.validate(OrderSchema.$id, {
...aboxFixtures.order,
'placedAt': 'not-a-timestamp'
})];
const formatErr = badErrs.find((err) => {
return err.keyword === 'format' && err.path === '/placedAt';
});
console.assert(formatErr !== undefined);
// 'format' — sub-schema constraint reached
console.log('format error keyword:', formatErr?.keyword);
// '/placedAt' — slot in parent schema
console.log('format error path:', formatErr?.path);
Format constraints on the referenced schema apply on the parent's slot. Transform decoders registered against the sub-schema's $id run on the parent's value too - one decoder, every reference.
TBox emits a typed property edge per $ref
/**
* Sub-schema patterns — TBox emits a typed property edge per $ref
*
* Every `$ref` in the TypeScript-side schema becomes a typed property
* edge in the canonical graph. The OWL projection emits
* `rdfs:domain` and `rdfs:range` for the parent class and the
* referenced class respectively.
*
* Demonstrated against the canonical `CustomerSchema`, which $refs
* `EmailSchema` for the `email` slot.
*/
import {
bookstoreEntities, EmailSchema
} from '../bookstore/index.js';
const owl = bookstoreEntities.ontology().jsonLdObject();
const graphNodes = owl['@graph'] as ReadonlyArray<Record<string, unknown>>;
// EmailSchema appears as its own class node in the OWL projection.
const emailNode = graphNodes.find((node) => {
return node['@id'] === EmailSchema.$id;
});
console.assert(emailNode !== undefined);
// Some property whose range is EmailSchema (e.g. https://bookstore.example/email)
// resolves via the typed property edge.
const RDFS_RANGE = 'http://www.w3.org/2000/01/rdf-schema#range';
const emailRangeProperty = graphNodes.find((node) => {
const range = node[RDFS_RANGE];
if (range === undefined) {
return false;
}
const refs = Array.isArray(range) ? range : [range];
return refs.some((ref) => {
return (ref as { readonly '@id'?: string })['@id'] === EmailSchema.$id;
});
});
console.assert(emailRangeProperty !== undefined);
// https://bookstore.example/Email
console.log('Email $id:', EmailSchema.$id);
// e.g. https://bookstore.example/email
console.log('property with email range:', emailRangeProperty?.['@id']);
Every $ref in the TypeScript-side schema becomes a typed property edge in the canonical graph. The OWL projection emits rdfs:domain and rdfs:range for the parent class and the referenced class respectively. SHACL emits sh:node or sh:datatype constraints on the property shape. The same graph drives both projections.
Composition through $refs: a discriminated union as a sub-schema
/**
* Sub-schema patterns — discriminated union as a sub-schema
*
* The composite is what the caller validates. Its `payment` slot is a
* `$ref` to a discriminated union. The validator descends through
* both layers automatically: variant selection happens inside the
* `$ref`, the rest of the order is checked at the top level.
*
* Demonstrated by extending the canonical `OrderSchema` with a
* `payment` slot whose value is a discriminated union over two
* payment-method sub-schemas.
*/
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'
},
'method': { 'const': 'credit_card' }
},
'required': [
'method',
'cardLast4'
],
'type': 'object'
} as const;
const InvoicePaymentSchema = {
'$id': 'https://bookstore.example/InvoicePayment',
'properties': {
'method': { 'const': 'invoice' },
'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 errs = jt2.validate(OrderWithPaymentSchema.$id, {
...aboxFixtures.order,
'payment': {
'cardLast4': '4242',
'method': 'credit_card'
}
});
console.assert(errs.length === 0);
// 0 — variant selected by discriminator
console.log('credit_card payment errors:', errs.length);
const invoiceErrs = jt2.validate(OrderWithPaymentSchema.$id, {
...aboxFixtures.order,
'payment': {
'method': 'invoice',
'purchaseOrder': 'PO-2026-001'
}
});
// 0 — other variant also valid
console.log('invoice payment errors:', invoiceErrs.length);
The composite (OrderWithPaymentSchema) is what the caller validates. Its payment slot is a $ref to the discriminated union. The validator descends through both layers automatically: variant selection happens inside the $ref, the rest of the order is checked at the top level.
Self-referential cycles
A sub-schema may $ref itself or any ancestor. The graph is allowed to be cyclic; the registry resolves a cycle by short-circuiting on the second visit, so type inference and runtime traversal both terminate.
/**
* Sub-schema patterns — self-referential cycles
*
* A sub-schema may `$ref` itself or any ancestor. The graph is allowed
* to be cyclic; the registry resolves a cycle by short-circuiting on
* the second visit, so type inference and runtime traversal both
* terminate. The OWL output emits a single class with an
* `rdfs:domain` / `rdfs:range` self-edge.
*
* Demonstrated against a small Manager → Manager hierarchy registered
* on ``. The fixture chain (Carl Conrad Coreander
* managing Bastian Balthazar Bux) is two levels deep.
*/
import {
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 ManagerSchema = {
'$id': 'https://bookstore.example/Manager',
'properties': {
'manager': { '$ref': 'https://bookstore.example/Manager' },
'name': { 'type': 'string' }
},
'required': ['name'],
'type': 'object'
} as const;
const jt2 = jt.set(ManagerSchema);
const bastian = jt2.instantiate(ManagerSchema.$id, {
'manager': {
'manager': { 'name': 'Carl Conrad Coreander' },
'name': 'Carl Conrad Coreander'
},
'name': 'Bastian Balthazar Bux'
}) as {
readonly 'manager': { readonly 'name': string };
readonly 'name': string;
};
console.assert(bastian.name === 'Bastian Balthazar Bux');
console.assert(bastian.manager.name === 'Carl Conrad Coreander');
// 'Bastian Balthazar Bux' — root node
console.log('name:', bastian.name);
// 'Carl Conrad Coreander' — nested via $ref cycle
console.log('manager name:', bastian.manager.name);
PersonSchema.manager references PersonSchema itself. Validation, instantiation, and TBox emission all handle the cycle without special configuration. The OWL output emits a single class with an rdfs:domain / rdfs:range self-edge.
Related
- Sub-schemas and
$refcomposition - declarative summary - Picking a method - validate vs instantiate vs materialize
- Composition: discriminatedUnion - oneOf as a sub-schema
- Composition: extend - merging properties without
$refindirection
See also
- Bookstore domain - every entity uses
$refcomposition - Graph concepts - TBox vs ABox, domain and range