Skip to content

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

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

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

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

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

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

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.


See also

Released under the MIT License.