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);
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:
$refpointing to the root schema's own$idresolves correctly on the interpreted path, matching the behaviour of the compiled path (registry.validate). - Embedded
$idin$defs:$reftargets that point at an$iddeclared 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.
Related
- Picking a method - validate vs instantiate vs materialize
- Graph-native authoring - why naming reduces drift
- Composition: discriminatedUnion - oneOf as a sub-schema
- Composition: extend - merging properties without
$refindirection - Sub-schema patterns - runnable code recipes
See also
- Bookstore domain - every entity uses
$refcomposition - Graph concepts - TBox vs ABox, domain and range
- Schemas - cross-schema
$refstrict resolution -REF_UNRESOLVEDerror