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
const ok = jt.validate(CustomerSchema.$id, {
id: 'c1a2b3d4-e5f6-7890-abcd-ef1234567890',
email: 'alice@bookstore.example'
});
console.log(ok.items.length === 0); // true
const bad = jt.validate(CustomerSchema.$id, {
id: 'c1a2b3d4-e5f6-7890-abcd-ef1234567890',
email: 'not-an-email'
});
console.log(bad.items[0].path); // '/email'
console.log(bad.items[0].keyword); // 'format'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
const PreferencesSchema = {
$id: 'urn:bookstore:Preferences',
type: 'object',
properties: {
locale: { type: 'string', default: 'en-US' },
notifications: { type: 'boolean', default: true }
}
} as const;
const ProfileSchema = {
$id: 'urn:bookstore:Profile',
type: 'object',
properties: {
customerId: { type: 'string' },
preferences: { $ref: PreferencesSchema.$id }
},
required: ['customerId']
} as const;
const jt = JsonTology.create({
baseIRI: 'urn:bookstore',
schemas: [PreferencesSchema, ProfileSchema] as const,
enableDefaults: true
});
const profile = jt.instantiate(ProfileSchema.$id, {
customerId: 'c1a2b3d4-e5f6-7890-abcd-ef1234567890',
preferences: {}
});
// profile.preferences === { locale: 'en-US', notifications: true }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
const Iso8601Schema = {
$id: 'urn:bookstore:Iso8601',
type: 'string',
format: 'date-time'
} as const;
const OrderSchema = {
$id: 'urn:bookstore:Order',
type: 'object',
properties: {
id: { type: 'string' },
placedAt: { $ref: Iso8601Schema.$id }
},
required: ['id', 'placedAt']
} as const;
const jt = JsonTology.create({
baseIRI: 'urn:bookstore',
schemas: [Iso8601Schema, OrderSchema] as const
});
const order = jt.instantiate(OrderSchema.$id, {
id: 'a1b2c3d4',
placedAt: '2026-01-15T10:30:00Z'
});
// throws InstantiationError when placedAt is not RFC 3339Format 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
const tbox = jt.toTbox();
// includes (among others):
// urn:bookstore:Customer schema:email urn:bookstore:Email
// urn:bookstore:Email rdf:type rdfs:DatatypeEvery $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
import { Compose } from 'json-tology';
const CreditCardPaymentSchema = {
$id: 'urn:bookstore:CreditCardPayment',
type: 'object',
properties: {
method: { const: 'credit_card' },
cardLast4: { type: 'string', pattern: '^\\d{4}$' }
},
required: ['method', 'cardLast4']
} as const;
const InvoicePaymentSchema = {
$id: 'urn:bookstore:InvoicePayment',
type: 'object',
properties: {
method: { const: 'invoice' },
purchaseOrder: { type: 'string' }
},
required: ['method', 'purchaseOrder']
} as const;
const PaymentSchema = Compose.discriminatedUnion(
'method',
[CreditCardPaymentSchema, InvoicePaymentSchema] as const,
'urn:bookstore:Payment'
);
const OrderWithPaymentSchema = Compose.extend(
OrderSchema,
{ payment: { $ref: PaymentSchema.$id } } as const,
'urn:bookstore:OrderWithPayment'
);
const jt = JsonTology.create({
baseIRI: 'urn:bookstore',
schemas: [
CreditCardPaymentSchema,
InvoicePaymentSchema,
PaymentSchema,
OrderSchema,
OrderWithPaymentSchema
] as const
});
const errs = jt.validate(OrderWithPaymentSchema.$id, {
id: 'a1b2c3d4',
placedAt: '2026-01-15T10:30:00Z',
payment: { method: 'credit_card', cardLast4: '4242' }
});
console.log(errs.items.length === 0); // trueThe 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.
const PersonSchema = {
$id: 'urn:bookstore:Person',
type: 'object',
properties: {
name: { type: 'string' },
manager: { $ref: 'urn:bookstore:Person' }
},
required: ['name']
} as const;
const jt = JsonTology.create({
baseIRI: 'urn:bookstore',
schemas: [PersonSchema] as const
});
const ceo = jt.instantiate(PersonSchema.$id, {
name: 'Carol',
manager: { name: 'Carol', manager: { name: 'Carol' } }
});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