Runtime decoding across packages
This page is for a consumer that (a) authors RDF/OWL hash-namespace schemas ($id: 'https://ns#Class'), (b) registers them in one package and calls instantiate / InferType from another — a monorepo with a schema-registry package and one or more consumer packages is the common shape — and (c) wants a fully typed result with no hand-rolled types. All three are supported today; this page walks the one end-to-end path that gets there, and the exact place instantiate's return type stops being trustworthy across the package boundary.
If you searched for REF_UNRESOLVED, unresolvedRef, RefNotFound, instantiate returns unknown, cross package, or monorepo, this is the page.
1. Author with CURIE $refs
A hash-namespace $id (https://ns#Class) is the idiomatic OWL form. The recommended way to reference a sibling schema in the same namespace is a CURIE $ref ('ns:Class'), not the expanded IRI:
/**
* Cross-package typing — the "producer" side.
*
* Stands in for a package that owns and registers a hash-namespace schema
* (`$id: 'https://ns#Class'`) — the idiomatic OWL form. `BookGenreSchema`
* references its sibling primitive `BookGenreLabelSchema` with a CURIE
* `$ref` (`'bk:BookGenreLabel'`), the recommended pattern for referencing a
* schema within the same hash namespace. `enableStrictGraph` is on by
* default (see /advanced/strict-graph-mode) and is satisfied here because
* `label` is a `$ref` to a registered schema, not an inline constrained
* shape.
*
* A consumer package imports `genreEntities` (the registry instance) to
* call `instantiate`, and imports the schema consts as types to build a
* local `InferType` reference map — see
* `examples/docs/cross-package/consumer-typed-instantiate.ts`.
*/
import { JsonTology } from '../../../src/index.js';
export const BookGenreLabelSchema = {
'$id': 'https://bookstore.example/ontology#BookGenreLabel',
'type': 'string'
} as const;
export const BookGenreSchema = {
'$id': 'https://bookstore.example/ontology#BookGenre',
'properties': { 'label': { '$ref': 'bk:BookGenreLabel' } },
'required': ['label'],
'type': 'object'
} as const;
export const genreEntities = JsonTology.create({
'baseIri': 'https://bookstore.example',
'prefixes': { 'bk': 'https://bookstore.example/ontology#' },
'schemas': [
BookGenreLabelSchema,
BookGenreSchema
]
});
BookGenreSchema.properties.label is { $ref: 'bk:BookGenreLabel' } — a CURIE, resolved against the bk prefix registered on JsonTology.create({ prefixes }).
2. Register in the producer package
JsonTology.create({ baseIri, prefixes, schemas }) registers both schemas and returns the registry instance (genreEntities above) that the producer package exports for consumers to call instantiate against. enableStrictGraph is on by default and is satisfied here: label is a $ref to a registered schema, not an inline constrained shape, so registration does not throw.
3. InferType with a CURIE-keyed reference map, in the consumer package
A consumer package that only imports the schema consts as types (not the runtime registry) derives its own precise type with InferType and a reference map keyed by the CURIE exactly as written in the $ref:
import type { InferType } from 'json-tology/types';
import type { BookGenreLabelSchema, BookGenreSchema } from '@your-org/schema-registry';
type BookGenreRefs = { 'bk:BookGenreLabel': typeof BookGenreLabelSchema };
type BookGenre = InferType<typeof BookGenreSchema, BookGenreRefs>;The map key is 'bk:BookGenreLabel' — the CURIE string as it appears in the schema's $ref — not the expanded IRI 'https://bookstore.example/ontology#BookGenreLabel'. Get this wrong (or omit the map entirely) and label resolves to RefNotFoundType<'bk:BookGenreLabel'> instead of string. See Troubleshooting: RefNotFoundType for that failure mode and why hand-rolling a replacement type is the wrong fix.
4. instantiate() called from the other package
The consumer package calls instantiate on the registry it imported from the producer package:
/**
* Cross-package typing — the "consumer" side.
*
* Imports the registry instance and the schema consts from the producer
* module (standing in for a separate npm package) and does the two things a
* cross-package consumer needs: validate untrusted data with `instantiate`,
* and get a precise local TypeScript type for the result via `InferType` +
* a CURIE-keyed reference map.
*
* `instantiate`'s own return type is `ParseOutputType<TSchema, TRefs>`,
* where `TRefs` is the *registry's* reference map — keyed by the absolute
* `$id`s passed to `JsonTology.create({ schemas })`. It does not expand a
* CURIE `$ref` the way a local CURIE-keyed `InferType` map does: `label`'s
* `$ref` is `'bk:BookGenreLabel'`, but `TRefs` only has an entry keyed
* `'https://bookstore.example/ontology#BookGenreLabel'`, so the assignment
* `const genre: BookGenre = raw;` fails to typecheck right here in this
* single compiled example — `raw.label` types as `RefNotFoundType<'bk:BookGenreLabel'>`,
* not `string`. Across a real package boundary this gets worse, not better:
* the producer's compiled `.d.ts` may not preserve `TRefs` at all, so a
* real cross-package `instantiate` call can type its fields as `unknown`
* (sometimes reported as "instantiate returns unknown" in a monorepo /
* cross package setup) even though the runtime value underneath is
* identical and fully validated.
*
* The recommended idiom is the same either way: re-derive the type locally
* with `InferType` and the same reference map, and read the validated
* runtime value into that local type instead of leaning on `instantiate`'s
* return type.
*/
import type { InferType } from '../../../src/types/index.js';
import type { BookGenreLabelSchema } from './producer-registry.js';
import {
BookGenreSchema, genreEntities
} from './producer-registry.js';
// Local reference map, keyed by the CURIE exactly as written in
// BookGenreSchema's $ref ('bk:BookGenreLabel') — not the expanded IRI.
type BookGenreRefs = { 'bk:BookGenreLabel': typeof BookGenreLabelSchema; };
type BookGenre = InferType<typeof BookGenreSchema, BookGenreRefs>;
// The runtime value: validated, defaults filled, decoders run — identical
// whether this call happens in the same package or a consumer package.
const raw = genreEntities.instantiate(BookGenreSchema.$id, { 'label': 'Fantasy' });
// Recommended idiom: read the validated value into the locally re-derived
// type. `raw`'s inferred type carries RefNotFoundType for `label` (see
// above), so the bridge is a double cast through `unknown` — it documents
// that instantiate's own return type cannot be trusted for this field, not
// that the runtime value itself is untrusted; the value was already
// validated by instantiate() before this line runs.
const genre: BookGenre = raw as unknown as BookGenre;
console.assert(genre.label === 'Fantasy');
console.log('genre.label:', genre.label);
console.log('Local type re-derived with InferType + CURIE-keyed reference map.');
5. Why instantiate's return type can't be trusted here
instantiate's return type is ParseOutputType<TSchema, TRefs>, where TRefs is the registry's reference map — built from the schemas array passed to JsonTology.create, and keyed by each schema's absolute $id. It is not the same map as the CURIE-keyed one you write locally for InferType, and it does not expand a CURIE $ref the way that local map does.
The example above proves this in a single compiled program, no package boundary required: genreEntities.instantiate(BookGenreSchema.$id, ...)'s inferred return type resolves label to RefNotFoundType<'bk:BookGenreLabel'>, because TRefs has an entry keyed 'https://bookstore.example/ontology#BookGenreLabel', not 'bk:BookGenreLabel'. Across a real package boundary this gets worse, not better — the producer's compiled .d.ts may not preserve TRefs intact at all, so a real cross-package instantiate call can type its fields as unknown (this is the "instantiate returns unknown" report). Either way, the runtime value is validated identically; only the type instantiate reports for it is unreliable across the boundary.
6. The recommended idiom: re-derive locally, read the value into it
Don't lean on instantiate's return type across a package boundary. Re-derive the type locally with InferType and the same reference map used everywhere else in the consumer package, and read the validated runtime value into that local type:
const raw = genreEntities.instantiate(BookGenreSchema.$id, data);
const genre: BookGenre = raw as unknown as BookGenre;The double cast documents that instantiate's own return type cannot be trusted for this field — not that the runtime value is untrusted. instantiate already validated data against the schema (including running any registered Transform decoders and filling defaults — see Canonical decode/default ordering) before this line runs; the cast only bridges the type, not the runtime check.
Summary
| Layer | What resolves the $ref | Keyed by |
|---|---|---|
Runtime (instantiate, validate) | The registry's RefDecoder | CURIE or absolute IRI, as registered — both work at runtime |
Local type (InferType<S, Refs>) | The Refs map you pass | The $ref string exactly as authored (CURIE or IRI) |
instantiate's return type (ParseOutputType<S, TRefs>) | The registry's own TRefs | The schema's absolute $id — does not expand a CURIE $ref, and may not survive a .d.ts boundary |
One authoring convention (CURIE $refs for a hash-namespace registry) serves the runtime and the type layer identically. The one thing it does not serve is instantiate's own return type across a package boundary — for that, re-derive locally.
Related
- Schemas:
$refcross-schema references - the runtime resolution rule - Types: InferType - reference maps,
RefNotFoundType, downstream-required defaults instantiatevsmaterialize- decode/default ordering- Your types are already a graph - the graph model these schemas sit in
- Strict graph mode -
enableStrictGraphand inline-shape enforcement