Skip to content

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
  ]
});
Output
Press Execute to run this example against the real library.

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:

ts
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.');
Output
Press Execute to run this example against the real library.

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.

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:

ts
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

LayerWhat resolves the $refKeyed by
Runtime (instantiate, validate)The registry's RefDecoderCURIE or absolute IRI, as registered — both work at runtime
Local type (InferType<S, Refs>)The Refs map you passThe $ref string exactly as authored (CURIE or IRI)
instantiate's return type (ParseOutputType<S, TRefs>)The registry's own TRefsThe 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.

Released under the MIT License.