Skip to content

Graph internals

These are the implementation details behind the canonical graph: how $ref resolves, how ABox projects to RDF, $id conventions, the irreducible jt:* vocabulary, and how to query the TBox programmatically. Prerequisite: Graph concepts.

JSON Pointer canonical identifiers

Every node in the schema graph has a stable identifier derived from JSON Pointer syntax.

Schema-level IRI: $id directly - urn:bookstore:Book.

Sub-schema IRI: $id + fragment - urn:bookstore:Book#/properties/isbn.

These stable pointers are used internally for:

  • $ref resolution: { $ref: 'urn:bookstore:Isbn' } resolves via the registry to the schema whose $id matches that string.
  • Anchor lookup: $anchor values establish named pointer aliases within a schema.
  • subschemaAt sub-schema selection: jt.subschemaAt(schema, '/properties/isbn') returns the isbn sub-schema node from the schema graph.

Instance paths vs schema paths: JSON Pointer appears in two distinct contexts:

ContextExamplePoints at
Schema pathurn:bookstore:Book#/properties/isbnA sub-schema node in the schema graph
Instance path/isbnA value in a data object

Schema paths are used for internal graph navigation and programmatic use. Instance paths appear in validation error messages.


Domain and range

Properties in OWL have rdfs:domain (the class the property belongs to) and rdfs:range (the class or datatype of its value).

json-tology derives these from the schema graph:

/**
 * Domain and range — how $ref produces rdfs:domain / rdfs:range edges.
 *
 * When a schema property uses `{ $ref: IsbnSchema.$id }`, the canonical graph
 * creates a typed property edge: the property's domain is the parent class
 * (Book) and its range is the referenced class (Isbn). The OWL TBox projects
 * these edges as `rdfs:domain` and `rdfs:range` triples.
 *
 * Demonstrates: inspecting the TBox JSON-LD output to verify domain/range
 * assertions are present for bookstore schemas.
 */

import {
  BookSchema, bookstoreEntities, IsbnSchema
} from '../bookstore/index.js';

// Generate the OWL TBox from the registered bookstore schemas
const tbox = bookstoreEntities.toTbox();
const jsonLd = tbox.jsonLd();

// The TBox string should reference both Book and Isbn class IRIs
console.assert(
  jsonLd.includes(BookSchema.$id),
  'TBox references the Book class IRI'
);
console.assert(
  jsonLd.includes(IsbnSchema.$id),
  'TBox references the Isbn class IRI'
);

// The JSON-LD object carries the graph nodes we can inspect
const jsonLdObj = tbox.jsonLdObject();

console.assert(
  Array.isArray(jsonLdObj['@graph']),
  'TBox JSON-LD contains a @graph array'
);

const graphNodes = jsonLdObj['@graph'] as unknown[];

console.log('TBox domain/range: Book in TBox:', jsonLd.includes(BookSchema.$id), '| Isbn in TBox:', jsonLd.includes(IsbnSchema.$id), '| graph nodes:', graphNodes.length);
Output
Press Execute to run this example against the real library.

This emits (in the TBox):

turtle
https://bookstore.example/isbn  rdfs:domain  urn:bookstore:Book .
https://bookstore.example/isbn  rdfs:range   urn:bookstore:Isbn .

For primitive string properties with a format hint, the range is an XSD datatype:

/**
 * Format → XSD datatype mapping in the TBox.
 *
 * Primitive schemas with a `format` hint emit an `rdfs:range` triple pointing
 * at the corresponding XSD datatype. PublicationDate (`format: 'date'`) maps
 * to `xsd:date`; Iso8601 (`format: 'date-time'`) maps to `xsd:dateTime`.
 *
 * Demonstrates: TBox JSON-LD carries XSD range assertions for date primitives.
 */

import {
  bookstoreEntities, Iso8601Schema, PublicationDateSchema
} from '../bookstore/index.js';

const tbox = bookstoreEntities.toTbox();
const jsonLd = tbox.jsonLd();

// Both bookstore date primitives must appear in the TBox
console.assert(
  jsonLd.includes(PublicationDateSchema.$id),
  'TBox includes PublicationDate class IRI'
);
console.assert(
  jsonLd.includes(Iso8601Schema.$id),
  'TBox includes Iso8601 class IRI'
);

// The XSD namespace must be referenced for date/dateTime range assertions
console.assert(
  jsonLd.includes('XMLSchema'),
  'TBox carries XSD namespace reference for date ranges'
);

console.log('XSD mapping: PublicationDate in TBox:', jsonLd.includes(PublicationDateSchema.$id), '| Iso8601 in TBox:', jsonLd.includes(Iso8601Schema.$id), '| XSD in TBox:', jsonLd.includes('XMLSchema'));
Output
Press Execute to run this example against the real library.

These emit:

turtle
urn:bookstore:PublicationDate  rdfs:range  xsd:date .
urn:bookstore:Iso8601          rdfs:range  xsd:dateTime .

Supported format → XSD mappings include: datexsd:date, date-timexsd:dateTime, timexsd:time, durationxsd:duration, uri/iri/uri-referencexsd:anyURI. Formats without an XSD equivalent (email, uuid, hostname, etc.) stay xsd:string.


$ref resolution

The schema graph is a directed graph, not a tree. $ref creates edges between nodes.

/**
 * $ref resolution — cross-schema edges in the canonical graph.
 *
 * `$ref` creates a directed edge in the schema graph from the parent schema
 * to the referenced schema. The registry resolves these edges at registration
 * time. The TBox projection follows these edges to emit `rdfs:range` assertions.
 *
 * BookSchema references IsbnSchema via `{ $ref: IsbnSchema.$id }`.
 * The graph edge Book → isbn → Isbn is visible in the TBox JSON-LD as a
 * property whose domain is Book and range is Isbn.
 *
 * Demonstrates: cross-schema $ref edges visible in TBox output.
 */

import {
  BookSchema, bookstoreEntities, IsbnSchema, TitleSchema
} from '../bookstore/index.js';

const tbox = bookstoreEntities.toTbox();
const jsonLd = tbox.jsonLd();

// Both ends of the $ref edge must appear in the TBox
console.assert(jsonLd.includes(BookSchema.$id), 'Book class in TBox');
console.assert(jsonLd.includes(IsbnSchema.$id), 'Isbn class in TBox (cross-schema $ref)');
console.assert(jsonLd.includes(TitleSchema.$id), 'Title class in TBox (cross-schema $ref)');

// The graph is a directed graph: the $ref becomes a typed property edge
const tboxGraph = tbox.jsonLdObject()['@graph'] as unknown[];

console.assert(tboxGraph.length > 0, 'TBox graph produced from cross-schema refs');

console.log('$ref resolution: Book in TBox:', jsonLd.includes(BookSchema.$id), '| Isbn in TBox:', jsonLd.includes(IsbnSchema.$id), '| graph nodes:', tboxGraph.length);
Output
Press Execute to run this example against the real library.

$defs entries live in the same namespace as their parent schema. They are part of that schema's ontology surface:

/**
 * $defs — internal schema namespace on the same graph node.
 *
 * `$defs` entries live in the same namespace as their parent schema. They are
 * part of that schema's ontology surface and are accessible as
 * `<parentId>#/$defs/<name>`. The graph edge from the parent to `$defs` sub-schemas
 * stays within the same schema node — unlike cross-schema `$ref` which creates
 * inter-schema edges.
 *
 * Demonstrates: a schema with $defs can reference its own definitions via
 * fragment pointers; the registry resolves them at validation time.
 */

import { SchemaRegistry } from '../../../src/modules/registry/SchemaRegistry.js';

// OrderWithDefs uses $defs to define LineItem internally
const OrderWithDefs = {
  '$defs': {
    'LineItem': {
      'properties': {
        'bookIsbn': { 'type': 'string' },
        'quantity': { 'type': 'integer' }
      },
      'required': [
        'bookIsbn',
        'quantity'
      ],
      'type': 'object'
    }
  },
  '$id': 'urn:bookstore:OrderWithDefs',
  'properties': {
    'items': {
      'items': { '$ref': '#/$defs/LineItem' },
      'type': 'array'
    }
  },
  'type': 'object'
} as const;

const registry = new SchemaRegistry();

registry.set(OrderWithDefs);

console.assert(registry.has(OrderWithDefs.$id), 'OrderWithDefs registered');

// The schema validates data with inline-defined LineItem structure
const lineItem = {
  'bookIsbn': '9783522128001',
  'quantity': 1
};
const valid = registry.validate(OrderWithDefs.$id, { 'items': [lineItem] });

console.assert(valid.ok, '$defs LineItem resolves at validation time');

// Invalid: quantity is missing
const missingQty = { 'bookIsbn': '9783522128001' };
const invalid = registry.validate(OrderWithDefs.$id, { 'items': [missingQty] });

console.assert(!invalid.ok, 'missing required quantity fails validation');

console.log('$defs namespace: OrderWithDefs registered:', registry.has(OrderWithDefs.$id), '| valid item ok:', valid.ok, '| missing field ok:', invalid.ok);
Output
Press Execute to run this example against the real library.

Here LineItem is accessible as urn:bookstore:Order#/$defs/LineItem - a node in the graph whose parent is urn:bookstore:Order.

Cross-schema $ref values resolve through the registry. A $ref is looked up by its IRI against all registered schemas. The graph edges connect nodes across schema boundaries.


Serializers

The canonical graph backs three serializers - see Ontology emission for the operator-level reference.


ABox projection

ABox projection round-trips typed data through RDF quads - see RDF round-trip.


$id IRI conventions

$id values are IRIs. Two conventions are common:

PrefixWhen to use
urn:Project-local schemas not published to the web
https://Web-resolvable schemas

The bookstore example uses urn:bookstore:{PascalCase} - e.g. urn:bookstore:Isbn, urn:bookstore:Book.

/**
 * baseIri — ontology document anchor for serializers.
 *
 * `baseIri` is passed to `JsonTology.create` to anchor the ontology document.
 * It does not need to match the `$id` prefixes of registered schemas — the
 * bookstore uses `urn:bookstore:*` $ids while `baseIri` is an HTTPS URL used
 * by serializers to expand CURIE prefixes and anchor relative IRIs.
 *
 * Demonstrates: the TBox JSON-LD context carries the baseIri namespace.
 */

import { bookstoreEntities } from '../bookstore/index.js';

// bookstoreEntities was constructed with baseIri: 'https://bookstore.example'
const tbox = bookstoreEntities.toTbox();
const context = tbox.context();

console.assert(typeof context === 'object', 'TBox carries a prefix context object');

const jsonLd = tbox.jsonLd();

console.assert(typeof jsonLd === 'string', 'TBox serializes to a JSON-LD string');
console.assert(jsonLd.length > 0, 'JSON-LD output is non-empty');

console.log('baseIri: context keys:', Object.keys(context), '| JSON-LD length:', jsonLd.length);
Output
Press Execute to run this example against the real library.

baseIri is used by the serializers to expand CURIE prefixes and anchor relative IRIs. It does not need to match the $id prefixes of the registered schemas - it is the base for the ontology document itself.


Querying the TBox

Once emitted as JSON-LD the TBox loads into any RDF store; standard SPARQL applies. See Querying the TBox for recipes.


The irreducible jt:* set

json-tology emits standard W3C vocabulary wherever possible. A small set of JSON Schema concepts has no standard counterpart and is represented using the jt: prefix:

KeywordWhy jt: is needed
jt:multipleOfDivisibility constraint - XSD and SHACL have no modulo predicate
jt:dependentRequiredSame SHACL gap - no standard property for co-required fields
jt:aliasInput-key normalization - a runtime concern for coercion, not an ontology property
jt:computedRuntime-derived property - no standard predicate for "computed at materialize time"
jt:strictPer-field validation behavior - a runtime coercion control, not an ontology property
jt:frozenObject.freeze output - a runtime effect, not an ontology concern
jt:configConfig bag - a composite of the above runtime concerns

Whenever a JSON Schema concept can be expressed in standard RDFS, OWL, SHACL, or XSD vocabulary, json-tology emits it that way. The jt:* predicates are reserved for the irreducibles.

JSON Schema if/then/else fragments are not currently emitted by either ShaclProjection or OwlProjection. These fragments are explicitly skipped during graph serialization.

GraphEngine

GraphEngine is the core validation and execution engine for compiled JSON Schema graphs. Instantiate once per root schema and reuse across calls.

The primary surface:

  • execute(value) — returns { valid, errors, value, … }
  • check(value) — fast boolean shortcut (no error collection)
  • errors(value) — returns only the ValidationErrorType[]
  • keywords() — returns any registered custom keyword definitions
  • rootSchemaId() — returns the $id of the root schema (or undefined)
/**
 * Compiled validation — validate, check, errors, custom keywords.
 *
 * `SchemaRegistry` provides compiled closure-based validation for registered
 * JSON Schema graphs. Register schemas once and reuse the validator across calls.
 *
 * Demonstrated here:
 *   - `registry.validator(id).validate(value)` — returns `{ valid, errors, value }`
 *   - `registry.validator(id).check(value)` — fast boolean shortcut
 *   - `registry.validator(id).validate(value, { collectErrors: true })` — full error collection
 *   - custom keyword registration via `RegistryOptionsType.keywords`
 */

import type { KeywordContextType } from '../../../src/types/GraphEngine.js';
import { SchemaRegistry } from '../../../src/modules/registry/SchemaRegistry.js';

// ── Schema under test ────────────────────────────────────────────────────────

const BookSchema = {
  '$id': 'https://bookstore.example/Book',
  'properties': {
    'pages': {
      'minimum': 1,
      'type': 'integer'
    },
    'title': { 'type': 'string' }
  },
  'required': ['title'],
  'type': 'object'
} as const;

// Register once — the registry compiles the schema graph on first validate call.
const registry = new SchemaRegistry({ 'enableStrictGraph': false });

registry.set(BookSchema);

const validator = registry.validator(BookSchema.$id);

// ── validate — valid data ─────────────────────────────────────────────────────

const validResult = validator.validate({
  'pages': 396,
  'title': 'The Neverending Story'
}, { 'collectErrors': true });

console.assert(validResult.valid, 'valid book must pass validate()');
console.assert(validResult.errors.length === 0, 'valid book must have zero errors');
console.log('validate(valid) — valid:', validResult.valid, 'errors:', validResult.errors.length);

// ── validate — invalid data ────────────────────────────────────────────────────

const invalidResult = validator.validate({ 'pages': 0 }, { 'collectErrors': true });

console.assert(!invalidResult.valid, 'book missing title must fail validate()');
console.assert(
  invalidResult.errors.length > 0,
  'invalid result must carry at least one error'
);
console.log(
  `validate(invalid) — valid: ${String(invalidResult.valid)}`,
  `errors: ${invalidResult.errors.length}`,
  `first keyword: ${invalidResult.errors[0]?.keyword}`
);

// ── check — fast boolean shortcut ────────────────────────────────────────────

const checkValid = validator.check({ 'title': 'Dune' });
const checkInvalid = validator.check({});

console.assert(checkValid, 'check() must return true for valid data');
console.assert(!checkInvalid, 'check() must return false for missing required field');
console.log('check(valid):', checkValid, '  check(invalid):', checkInvalid);

// ── full error collection ─────────────────────────────────────────────────────

const errorResult = validator.validate({ 'pages': -1 }, { 'collectErrors': true });

console.assert(Array.isArray(errorResult.errors), 'errors must be an array');
console.assert(errorResult.errors.length > 0, 'must report at least one error for invalid data');
console.log('errors for { pages: -1 }:', errorResult.errors.length);

// ── custom keyword registration ───────────────────────────────────────────────

// Custom keywords are registered at the registry level and apply to all
// compiled validators created by that registry.
const customRegistry = new SchemaRegistry({
  'enableStrictGraph': false,
  'keywords': [{
    'keyword': 'x-example-tag',
    'type': 'string',
    'validate': (_schema: unknown, _data: unknown, _ctx: KeywordContextType): boolean => {
      return true;
    }
  }]
});

customRegistry.set(BookSchema);

const customResult = customRegistry.validator(BookSchema.$id).validate({ 'title': 'Dune' });

console.assert(customResult.valid, 'custom registry must validate correctly');
console.log('custom registry validate(valid).valid:', customResult.valid);
Output
Press Execute to run this example against the real library.

See also

Released under the MIT License.