Skip to content

Sub-schemas and $ref composition Compile-time + Runtime

Validation modes: Validation modes reference

A schema becomes a sub-schema of another by $ref-ing its $id. The parent owns the property slot; the registry resolves the reference at validation, instantiation, materialization, and TBox-emit time. The wire format stays a single declarative literal - no JS-level inheritance, no inlining.

This page states what holds across each of the four core operations. For runnable code, see Sub-schema patterns.

All examples use the bookstore domain.


Pattern: name a value, reference it everywhere

/**
 * Sub-schema $ref pattern — name a value, reference it everywhere.
 *
 * Define the constrained value type once as a named schema (`EmailSchema`).
 * Any schema that needs an email field references it via `{ $ref: EmailSchema.$id }`.
 * Changing `EmailSchema` propagates to every consumer simultaneously.
 * `findDuplicates()` will never flag two `$ref` slots as redundant.
 *
 * Demonstrates: EmailSchema referenced from CustomerSchema; both validate
 * against the shared constraint.
 */

import {
  bookstoreEntities,
  CustomerSchema,
  EmailSchema
} from '../bookstore/index.js';

// EmailSchema is the canonical definition of the email value type.
// CustomerSchema references it via $ref — the reference is symbolic, not structural.

// Valid: email passes EmailSchema constraint
const validEmail = 'bastian.bux@bookstore.example';
const customer = {
  'addresses': [],
  'customerId': 'c1a2b3d4-e5f6-7890-abcd-ef1234567890',
  'email': validEmail,
  'name': 'Bastian Balthazar Bux'
};

const customerResult = bookstoreEntities.validate(CustomerSchema.$id, customer);

// ok is true when the ValidationErrors collection is empty (no errors)
console.assert(customerResult.ok, 'customer with valid email passes validation');

// The email constraint is shared — validate it directly through EmailSchema too
const emailResult = bookstoreEntities.validate(EmailSchema.$id, validEmail);

console.assert(emailResult.ok, 'email value validates directly against EmailSchema');

// Invalid email fails at the customer boundary (error path points at /email)
const invalidCustomer = {
  ...customer,
  'email': 'not-an-email'
};
const invalidResult = bookstoreEntities.validate(CustomerSchema.$id, invalidCustomer);

console.assert(!invalidResult.ok, 'customer with invalid email fails validation');

const hasEmailPointer = invalidResult.items.some((item) => {
  return item.path === '/email';
});

console.assert(
  hasEmailPointer,
  'error path targets /email — the $ref slot in CustomerSchema'
);

console.log('Valid customer — validation ok:', customerResult.ok);
console.log('EmailSchema direct validation ok:', emailResult.ok);
console.log('Invalid email — validation fails:', !invalidResult.ok);
console.log('Error path targets $ref slot /email:', hasEmailPointer);
Output
Press Execute to run this example against the real library.

EmailSchema is the canonical definition of the value type "email." Any schema that wants an email field references its $id. The reference is symbolic, not structural - changing EmailSchema changes every consumer at once, and findDuplicates() will not flag two $ref slots as redundant.


Validation reaches into $refs

The validator follows $ref to the referenced schema and applies its constraints on the parent's slot. Error paths point at the parent's slot (e.g. /email), not at the referenced schema. Callers see one validation surface per request.


Defaults from sub-schemas flow through 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 (a $ref to a schema that itself has a $ref) all resolve in a single pass.


Coercion respects sub-schema constraints and Transforms

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

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

Composite operators like Compose.discriminatedUnion and Compose.extend produce schemas that other schemas can $ref like any other registered entity. The validator descends through both layers automatically: variant selection or property merging happens inside the $ref, the rest of the parent is checked at the top level.


Cycles are first-class

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. Validation, instantiation, and TBox emission all handle cycles without special configuration. The OWL output for a self-referential schema emits a single class with an rdfs:domain / rdfs:range self-edge.


Worked patterns

See Sub-schema patterns for runnable code: validation through $refs, defaults flow, coercion, TBox emission, composition with discriminatedUnion, and self-referential cycles.


What you give up by inlining

Any of the patterns above can be written without $refs by inlining the sub-schema body into the parent. That works, but it costs:

  • The TypeScript type loses its name (you get the structural shape, not the named type).
  • Two inline copies don't share a single ontology class - the OWL output emits two anonymous classes.
  • findDuplicates() flags the two inline shapes as redundant.
  • A change to the sub-schema requires updating every inline copy by hand.

For ergonomic, refactor-safe, ontology-friendly authoring: name every value type and $ref it.


GraphEngine self-ref and embedded $id resolution Runtime

When accessing the engine directly via registry.engine(schemaObj).errors(data), self-references and embedded $id declarations inside $defs resolve correctly on both the interpreted engine path and the compiled registry.validate fast-path.

  • Self-references: $ref pointing to the root schema's own $id resolves correctly on the interpreted path, matching the behaviour of the compiled path (registry.validate).
  • Embedded $id in $defs: $ref targets that point at an $id declared inside $defs (or any nested sub-schema) resolve on both the compiled and interpreted paths.

The two paths produce the same validation results for all $ref shapes.

See also

Released under the MIT License.