Schema federation
json-tology resolves $ref IRIs at registration time. By default, all referenced schemas must be registered before first use. The registry throws GraphError('REF_UNRESOLVED') if a non-fragment IRI points to an unregistered schema.
The loader hook is a single async function that fetches schemas on demand:
/**
* LoaderType — the loader function signature for JsonTology.prefetch.
*
* A loader is any async function that takes an IRI string and returns either
* the schema object or `null` (IRI unknown). This is the only type required
* to implement a custom loader.
*
* `Loaders.fetch`, `Loaders.memory`, `Loaders.compose`, and `Loaders.cached`
* all return functions conforming to this signature.
*
* Demonstrates: the LoaderType contract — returns the schema for known IRIs,
* null for unknown IRIs, never throws for expected misses.
*/
import { Loaders } from '../../../src/index.js';
import type { JsonSchemaType } from '../../../src/types/Schema.js';
import {
BookSchema,
CustomerSchema
} from '../bookstore/index.js';
// Loaders.memory returns a LoaderType — callable with (iri: string)
const loader = Loaders.memory(new Map<string, JsonSchemaType>([
[
BookSchema.$id,
BookSchema
],
[
CustomerSchema.$id,
CustomerSchema
]
]));
// Returns the schema for known IRIs
const resolved = await loader(CustomerSchema.$id);
console.assert(resolved !== null, 'known IRI returns schema (not null)');
console.assert(typeof resolved === 'object', 'resolved schema is an object');
// Returns null for unknown IRIs — no throw
const unknown = await loader('urn:bookstore:Unknown');
console.assert(unknown === null, 'unknown IRI returns null per LoaderType contract');
console.log('LoaderType: resolved schema $id:', (resolved as Record<string, string>).$id, '| unknown IRI returns null:', unknown === null);
Async work is isolated to a single entry point: JsonTology.prefetch, which builds a snapshot. JsonTology.create is synchronous on every call site and consumes the snapshot through the prefetched option.
Prefetch + sync create
JsonTology.prefetch walks transitive $refs via the loader and returns a snapshot. The snapshot is loader-agnostic; pass it to create() via the prefetched option for sync consumption.
/**
* Bundler pattern — pre-bundle local schemas with network fallback.
*
* In a bundler context (Vite, esbuild, webpack), local schemas are imported
* at build time and seeded into `Loaders.memory`. A `Loaders.compose` chain
* tries memory first; for any IRI not in the bundle the chain falls back to
* the next loader (here a stub that returns null so the example runs
* deterministically offline — in production this slot carries
* `Loaders.fetch({ base: '…' })`).
*
* Demonstrates: Loaders.compose + Loaders.memory seeded from the canonical
* bookstore schema set + prefetch + synchronous create.
*/
import {
JsonTology,
Loaders
} from '../../../src/index.js';
import {
bookstoreSchemas,
CustomerSchema,
OrderSchema
} from '../bookstore/index.js';
// Network-fallback stub — in production replace with
// `Loaders.fetch({ base: 'https://schemas.example/v1/' })`. Returning null
// lets `Loaders.compose` move on to the next loader (none here) and keeps
// the example runnable in offline test environments.
const offlineFallback = (): Promise<null> => {
return Promise.resolve(null);
};
const snapshot = await JsonTology.prefetch({
'loader': Loaders.compose(
// Pre-bundled schemas — memory fast path, no network for known IRIs.
Loaders.memory(new Map(bookstoreSchemas.map((schema) => {
return [
schema.$id,
schema
] as const;
}))),
offlineFallback
),
'schemas': [
CustomerSchema,
OrderSchema
]
});
const jt = JsonTology.create({
'baseIri': 'https://bookstore.example',
'prefetched': snapshot,
'schemas': [
CustomerSchema,
OrderSchema
] as const
});
// validate is synchronous — async work was isolated to prefetch
const result = jt.validate(CustomerSchema.$id, {
'addresses': [],
'customerId': 'c1a2b3d4-e5f6-7890-abcd-ef1234567890',
'email': 'bastian.bux@bookstore.example',
'name': 'Bastian Balthazar Bux'
});
console.assert(result.ok, 'Customer validates against prefetched registry');
console.log('Prefetch + sync create: snapshot schemas:', snapshot.schemas.size, '| validate ok:', result.ok);
prefetch accepts:
loader: required.schemas: seed schemas whose refs are followed.rootIds: IRIs to load directly from the loader (no local seed required).baseIri: used by the ephemeral walker; defaults to a static placeholder when omitted.
How the resolution walk works
prefetchregisters anyschemasprovided as seeds, then loads eachrootIdsIRI.- The walker iterates registered schemas and collects every non-fragment cross-schema
$refIRI not yet present. - Each unresolved IRI is passed to the loader. If the loader returns
null, the walk throwsGraphError('REF_UNRESOLVED')with the offending IRI inerr.pointer. - Returned schemas are registered and recursed into; their own
$refs are added to the queue. - A
Set<string>of visited IRIs prevents calling the loader twice for the same IRI. - The walker captures every resolved schema into
snapshot.schemaskeyed by$id.
Built-in loader helpers
The Loaders namespace ships four universal helpers that work in Node ≥ 18, Bun, Deno, and browsers.
Loaders.fetch
Uses globalThis.fetch. Works anywhere. 4xx/5xx → null. Network errors propagate.
/**
* Loaders.fetch — universal HTTP loader with base URL and header options.
*
* `Loaders.fetch` uses `globalThis.fetch` and works in Node ≥ 18, Bun,
* Deno, and browsers. Options:
* - No options: fetches the IRI directly.
* - `base`: resolves relative IRIs against the base URL.
* - `init`: passes RequestInit overrides (headers, credentials, etc.).
*
* Demonstrates: Loaders.fetch is callable with all three option shapes;
* all three return a valid LoaderType function.
*/
import { Loaders } from '../../../src/index.js';
// No options — fetch from the IRI directly
const direct = Loaders.fetch();
// With base URL — resolve relative IRIs against the base
const withBase = Loaders.fetch({ 'base': 'https://schemas.example/v1/' });
// With RequestInit — add auth headers
const withHeaders = Loaders.fetch({ 'init': { 'headers': { 'X-Api-Key': 'demo-key' } } });
// All three produce LoaderType functions
console.assert(typeof direct === 'function', 'Loaders.fetch() returns a function');
console.assert(typeof withBase === 'function', 'Loaders.fetch({ base }) returns a function');
console.assert(typeof withHeaders === 'function', 'Loaders.fetch({ init }) returns a function');
console.log('Loaders.fetch variants: direct:', typeof direct, '| withBase:', typeof withBase, '| withHeaders:', typeof withHeaders);
Loaders.memory
In-memory lookup. Accepts a Map or plain object. Zero I/O.
/**
* Loaders.memory — in-memory loader for local schema bundles.
*
* `Loaders.memory` accepts a `Map` or plain object keyed by $id IRI. It
* returns a `LoaderType` that resolves schemas with zero I/O — useful for
* pre-bundled schemas at build time and for testing.
*
* Demonstrates: Loaders.memory with bookstore schemas as the lookup map.
*/
import { Loaders } from '../../../src/index.js';
import type { JsonSchemaType } from '../../../src/types/Schema.js';
import {
BookSchema,
CustomerSchema,
IsbnSchema
} from '../bookstore/index.js';
// Build an in-memory Map keyed by $id IRI — explicit type annotation satisfies Loaders.memory
const memLoader = Loaders.memory(new Map<string, JsonSchemaType>([
[
BookSchema.$id,
BookSchema
],
[
CustomerSchema.$id,
CustomerSchema
],
[
IsbnSchema.$id,
IsbnSchema
]
]));
// Resolve a known IRI — returns the schema
const resolved = await memLoader(CustomerSchema.$id);
console.assert(resolved !== null, 'known IRI resolves from memory');
console.assert(
(resolved as Record<string, string>).$id === CustomerSchema.$id,
'resolved schema carries the expected $id'
);
// Resolve an unknown IRI — returns null (no throw)
const unknown = await memLoader('urn:bookstore:NoSuchSchema');
console.assert(unknown === null, 'unknown IRI returns null');
console.log('Loaders.memory: resolved $id:', (resolved as Record<string, string>).$id, '| unknown IRI:', unknown);
Loaders.compose
Chains multiple loaders. Returns the first non-null result.
/**
* Loaders.compose — chain multiple loaders with first-non-null wins.
*
* `Loaders.compose` tries each loader in order and returns the first non-null
* result. The fast path is an in-memory bundle of locally-known schemas;
* the fallback is a network loader.
*
* Demonstrates: Loaders.compose + Loaders.memory + Loaders.fetch for a
* "local-first, network-fallback" resolution strategy.
*/
import { Loaders } from '../../../src/index.js';
import type { JsonSchemaType } from '../../../src/types/Schema.js';
import {
BookSchema,
CustomerSchema,
IsbnSchema
} from '../bookstore/index.js';
const composed = Loaders.compose(
// Locally-known schemas served from memory — no network for known IRIs
Loaders.memory(new Map<string, JsonSchemaType>([
[
BookSchema.$id,
BookSchema
],
[
CustomerSchema.$id,
CustomerSchema
],
[
IsbnSchema.$id,
IsbnSchema
]
])),
// Fallback for IRIs not in the local bundle
Loaders.fetch({ 'base': 'https://schemas.example/v1/' })
);
// The composed loader resolves from memory — no network needed for known IRIs
const customer = await composed(CustomerSchema.$id);
console.assert(customer !== null, 'composed loader resolves known IRI from memory');
console.assert(
(customer as Record<string, string>).$id === CustomerSchema.$id,
'resolved schema is the CustomerSchema'
);
console.log('Loaders.compose: resolved from memory, $id:', (customer as Record<string, string>).$id);
Loaders.cached
Wraps any loader with an LRU cache (default: 1024 entries). Both resolved schemas and null results are cached so the inner loader is called at most once per IRI.
/**
* Loaders.cached — LRU-cached loader wrapper.
*
* `Loaders.cached` wraps any loader and caches both resolved schemas and `null`
* results so the inner loader is called at most once per IRI. Both positive and
* negative results are cached (negative caching avoids repeated failed fetches).
*
* Demonstrates: Loaders.cached wrapping Loaders.memory, with custom maxSize.
*/
import { Loaders } from '../../../src/index.js';
import type { JsonSchemaType } from '../../../src/types/Schema.js';
import {
BookSchema,
CustomerSchema
} from '../bookstore/index.js';
const inner = Loaders.memory(new Map<string, JsonSchemaType>([
[
BookSchema.$id,
BookSchema
],
[
CustomerSchema.$id,
CustomerSchema
]
]));
// Wrap with a small LRU cache (256 entries)
const cached = Loaders.cached(inner, { 'maxSize': 256 });
// First call populates the cache
const first = await cached(CustomerSchema.$id);
console.assert(first !== null, 'first call resolves');
// Second call returns from cache — inner loader not called again
const second = await cached(CustomerSchema.$id);
console.assert(
(second as Record<string, string>).$id === CustomerSchema.$id,
'cached result carries the same $id'
);
// Null results are also cached
const miss = await cached('urn:bookstore:NoSuchSchema');
console.assert(miss === null, 'unknown IRI returns null');
console.log('Loaders.cached: first call $id:', (first as Record<string, string>).$id, '| miss (null):', miss);
Write your own loader
Any function with the signature (iri: string) => Promise<JsonSchemaType | null> is a valid loader. Node fs example:
/**
* Custom in-memory loader — resolve schemas from a pre-built map.
*
* Any function with signature `(iri: string) => Promise<JsonSchemaType | null>`
* is a valid loader. This example builds a small in-memory map of schema IRI
* to schema object and a loader function that resolves from it — no disk I/O,
* no Node built-ins, runs identically in browsers, workers, and Node.
*
* In a real setup the map would be populated from a bundled import or a prior
* fetch; here we use the bookstore schemas directly so the example is
* self-contained and verifiable.
*
* Demonstrates: custom loader function, `null` on miss, in-memory pattern.
*/
import {
CustomerSchema,
IsbnSchema
} from '../bookstore/index.js';
/** In-memory schema store keyed by schema $id. */
const schemaMap = new Map<string, Record<string, unknown>>([
[
CustomerSchema.$id,
CustomerSchema
],
[
IsbnSchema.$id,
IsbnSchema
]
]);
/**
* Loader: resolves a schema IRI from the in-memory map.
* Returns `null` for IRIs that have no registered entry.
*/
const memoryLoader = async (iri: string): Promise<null | Record<string, unknown>> => {
return schemaMap.get(iri) ?? null;
};
// Loader resolves a known schema IRI
const customerResult = await memoryLoader(CustomerSchema.$id);
console.assert(customerResult !== null, 'known schema IRI resolves via memory loader');
console.assert(
customerResult !== null && customerResult.$id === CustomerSchema.$id,
'resolved schema carries correct $id'
);
console.log('customerResult.$id:', customerResult?.$id);
// IsbnSchema also resolves
const isbnResult = await memoryLoader(IsbnSchema.$id);
console.assert(isbnResult !== null, 'Isbn schema resolves from map');
console.log('isbnResult.$id:', isbnResult?.$id);
// Unknown IRI returns null without throwing
const unknown = await memoryLoader('urn:bookstore:NoSuchThing');
console.assert(unknown === null, 'unknown IRI returns null');
console.log('unknown IRI returns null:', unknown === null);
Adding schemas after construction
jt.set(schema) is synchronous and throws REF_UNRESOLVED for any unregistered cross-schema refs. For schemas whose transitive refs are not yet known locally, build a fresh snapshot with JsonTology.prefetch and pass it through prefetched on a new JsonTology.create call, or merge the new schemas into the existing registry by calling set() once every dependency is in scope.
Performance notes
- Cache the loader. Wrap with
Loaders.cached()so schemas fetched in one walk are not re-fetched ifprefetchis called again. - Bundle critical schemas.
Loaders.compose(Loaders.memory(bundled), Loaders.fetch(...))serves critical schemas from memory with the network as fallback. - Snapshot at build time. Run
prefetchin a build step and persist the resultingsnapshot.schemasmap; consume it at runtime with zero network calls.
Error handling
| Condition | Result |
|---|---|
Loader returns null for a required IRI | GraphError('REF_UNRESOLVED') with the IRI in err.pointer |
| Loader throws (network error) | Error propagates; callers see the real failure |
Loader returns a schema with new unresolved $refs | Those IRIs are queued and resolved transitively |
| Same IRI encountered twice in one walk | Loader called at most once (visited-set dedup) |
Comparison with similar mechanisms
| Feature | json-tology | AJV loadSchema | SPARQL SERVICE |
|---|---|---|---|
| Protocol-agnostic | Yes | Yes | HTTP only |
Sync create() after prefetch | Yes | No (async compile) | N/A |
| Cycle detection | Visited-set dedup | Manual | Depends on engine |
| Universal (Node/browser) | Yes | Yes | Server-side only |
| Built-in caching | Loaders.cached | None | Endpoint-level |