Skip to content

Getting Started

json-tology is an ontology-native type system for TypeScript. Declare schemas once in JSON Schema, get TypeScript types, runtime validation, defaults, transforms, and serialization from one canonical graph.

Install

bash
npm install json-tology

Requires Node.js >=24.0.0.

Supported dialect: JSON Schema draft 2020-12 (https://json-schema.org/draft/2020-12/schema).

Upgrading from 0.3.x? See Migration to 0.4.0 for the breaking changes.

Define a schema

Schemas are plain JSON Schema objects with $id and as const. They are interoperable with the wider JSON Schema ecosystem - for example, sourcemeta/jsonschema will lint, bundle, and format the same files. The bookstore domain used in all examples follows the one-file-per-concept pattern. See the Bookstore Domain page for the full folder layout and all schemas.

Primitives are named, reusable schemas with a urn: IRI:

/**
 * Getting started: primitive schema — named, single source of truth
 *
 * Primitives are small, focused schemas with a `urn:` IRI. Every concept
 * lives in its own file so multiple entity schemas can `$ref` the same
 * primitive without duplicating the validation rule.
 *
 * `CustomerIdSchema` is the canonical UUID primitive for customer identity.
 * It is imported by `CustomerSchema` via `{ $ref: CustomerIdSchema.$id }`.
 */

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

// The canonical primitive: type + format, one concept per schema.
const id: string = CustomerIdSchema.$id;

console.assert(id === 'urn:bookstore:CustomerId');

const schemaType: string = CustomerIdSchema.type;
const schemaFormat: string = CustomerIdSchema.format;

console.assert(schemaType === 'string');
console.assert(schemaFormat === 'uuid');

console.log('$id:', id);
console.log('type:', schemaType);
console.log('format:', schemaFormat);
Output
Press Execute to run this example against the real library.

Entities compose primitives via $ref: SourceSchema.$id - never bare string literals:

/**
 * Getting started: entity schema — composed of named primitives via $ref
 *
 * Entity schemas reference primitive schemas by IRI rather than repeating
 * validation rules inline. Every `$ref` value is `SourceSchema.$id` —
 * an explicit named import at the top of the file, never a bare string.
 *
 * `CustomerSchema` composes `CustomerId`, `Email`, and `PersonName` into
 * a single entity with all three fields required.
 */

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

const schemaId: string = CustomerSchema.$id;
const schemaType: string = CustomerSchema.type;

console.assert(schemaId === 'urn:bookstore:Customer');
console.assert(schemaType === 'object');
console.assert(CustomerSchema.required.includes('customerId'));
console.assert(CustomerSchema.required.includes('email'));
console.assert(CustomerSchema.required.includes('name'));

console.log('$id:', schemaId);
console.log('type:', schemaType);
console.log('required:', CustomerSchema.required.join(', '));
Output
Press Execute to run this example against the real library.

as const is required. Without it TypeScript widens every string literal and InferType<T> cannot produce the right type.

Derive the TypeScript type

/**
 * Getting started: InferType — derive a TypeScript type from a schema
 *
 * `InferType<T>` reads the literal types from a schema constant and
 * produces the corresponding TypeScript type at compile time. No code
 * generation, no separate declaration file. The type is derived directly
 * from the `as const` schema literal.
 *
 * Format keywords (uuid, email) produce `string & FormatBrand<'uuid'>` etc.
 * so the branded types keep domain concepts nominally distinct even though
 * their runtime representation is `string`.
 */

import {
  bookstoreEntities, CustomerSchema
} from '../bookstore/index.js';
import type { Customer } from '../bookstore/index.js';

// InferType<typeof CustomerSchema, BookstoreRefs> resolves every $ref property
// to its named branded datatype. `Customer` is that resolved type; it is
// re-exported from the bookstore index for convenience.
//
// Branded values (FormatBrand<'uuid'>, etc.) can only be produced by registry
// methods (instantiate / value.create / materialize). Hand-written literals do
// not carry the brand, so construct via instantiate.
const bastian: Customer = bookstoreEntities.instantiate(CustomerSchema, {
  'addresses': [],
  'customerId': 'c1a2b3d4-e5f6-7890-abcd-ef1234567890',
  'email': 'bastian.bux@bookstore.example',
  'name': 'Bastian Balthazar Bux'
});

console.assert(bastian.name === 'Bastian Balthazar Bux');
console.assert(typeof bastian.customerId === 'string');
console.assert(typeof bastian.email === 'string');
console.assert(Array.isArray(bastian.addresses));

console.log('name:', bastian.name);
console.log('email:', bastian.email);
console.log('addresses:', (bastian.addresses ?? []).length);
Output
Press Execute to run this example against the real library.

No code generation. No separate type declaration file. The type comes directly from the schema literal at compile time.

Create an instance and register schemas

/**
 * Getting started: JsonTology.create — register schemas and build the type map
 *
 * `JsonTology.create` takes `baseIri` and `schemas` (as const array),
 * registers all schemas, compiles the validation graph, and builds the
 * type map. Every subsequent method call that accepts a schema `$id`
 * returns typed results from that map.
 *
 * The bookstore domain uses this pattern in `examples/docs/bookstore/index.ts`
 * with all 31 schemas pre-registered. This example focuses on the minimal
 * single-schema form to show the core contract.
 */

import { JsonTology } from '../../../src/index.js';
import {
  bookstoreSchemas, CustomerSchema
} from '../bookstore/index.js';

// bookstoreSchemas seeds every transitive $ref so CustomerSchema's
// references to AddressSchema, EmailSchema, etc. all resolve.
const jt = JsonTology.create({
  'baseIri': 'https://bookstore.example',
  'schemas': bookstoreSchemas
});

// Validate Bastian Balthazar Bux — zero errors confirms schema is registered.
const errs = jt.validate(CustomerSchema.$id, {
  'addresses': [],
  'customerId': 'c1a2b3d4-e5f6-7890-abcd-ef1234567890',
  'email': 'bastian.bux@bookstore.example',
  'name': 'Bastian Balthazar Bux'
});

console.assert(errs.length === 0);

console.log('schema $id:', CustomerSchema.$id);
console.log('validation errors:', errs.length);
Output
Press Execute to run this example against the real library.

JsonTology.create() registers all schemas, compiles the validation graph, and builds the type map. Every method that accepts a schema $id returns typed results from that map.

Validate

validate() returns a ValidationErrors collection. An empty collection (errs.ok === true) means valid.

/**
 * Getting started: validate — Check data against a schema
 *
 * Demonstrates: validate returns ValidationErrors (no throw, no value)
 * Uses the canonical Bastian Balthazar Bux customer fixture.
 */

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

// Valid customer — all required fields present
const errs = bookstoreEntities.validate(CustomerSchema.$id, aboxFixtures.customer);

console.assert(errs.length === 0);

// Invalid customer — missing required fields
const badCustomer = { 'email': aboxFixtures.customer.email };
const badErrs = bookstoreEntities.validate(CustomerSchema.$id, badCustomer);

console.assert(badErrs.length > 0);

// Inspect the errors
for (const err of badErrs) {
  console.assert(err.keyword === 'required');
}

console.log('valid customer errors:', errs.length);
console.log('invalid customer errors:', badErrs.length);
console.log('first error keyword:', badErrs.items[0]?.keyword);
Output
Press Execute to run this example against the real library.

See Validation for is(), validate(), subschemaAt(), and the structured error views.

Instantiate

instantiate() validates, applies defaults, strips unknown properties, and returns a typed value. Throws InstantiationError on failure.

import { JsonTology } from '../../../src/index.js';

const AddressSchema = {
  '$id': 'https://bookstore.example/Address',
  'properties': {
    'city': { 'type': 'string' },
    'country': {
      'default': 'US',
      'type': 'string'
    },
    'postalCode': { 'type': 'string' },
    'street': { 'type': 'string' }
  },
  'required': [
    'street',
    'city',
    'postalCode'
  ],
  'type': 'object'
} as const;

// doc example with synthetic fixture schemas (strict-graph default does not throw because no inline duplicates)
const jt = JsonTology.create({
  'baseIri': 'https://bookstore.example',
  'schemas': [AddressSchema] as const
});

const jt2 = jt.set(AddressSchema);

// country omitted - default 'US' applied
// stripped
const address = jt2.instantiate(AddressSchema.$id, {
  'city': 'Bookham',
  'extra': 'ignored',
  'postalCode': '94107',
  'street': '12 Elm Lane'
});

// { street: '12 Elm Lane', city: 'Bookham', postalCode: '94107', country: 'US' }
console.assert(address.country === 'US');

console.log('street:', address.street);
console.log('city:', address.city);
console.log('country (default applied):', address.country);
Output
Press Execute to run this example against the real library.

Compose schemas

Compose derives new schemas from existing ones. All composition runs at compile time and produces correct JSON Schema objects.

/**
 * Compose.partial / Compose.pick against the canonical Customer.
 *
 * Two derived schemas registered onto the canonical bookstore:
 *   • PatchCustomerSchema — every Customer field optional (PATCH body).
 *   • CustomerSummarySchema — id + name only (list-view projection).
 */

import { Compose } from '../../../src/index.js';
import {
  aboxFixtures, createBookstoreDocRegistry,
  CustomerSchema
} from '../bookstore/index.js';

// createBookstoreDocRegistry seeds a permissive copy of the bookstore — docs examples extend
// it with ad-hoc demo schemas; strict-graph checking is intentionally off here.
const jt = createBookstoreDocRegistry();

const PatchCustomerSchema = Compose.partial(
  CustomerSchema,
  'https://bookstore.example/PatchCustomer'
);

const CustomerSummarySchema = Compose.pick(
  CustomerSchema,
  [
    'customerId',
    'name'
  ] as const,
  'https://bookstore.example/CustomerSummary'
);

jt.set(PatchCustomerSchema);
jt.set(CustomerSummarySchema);

// These derived schemas are not part of the registry's compile-time schema-ID
// union (registered at runtime via set()), so validate/instantiate receive the
// schema object itself.
const patchErrs = jt.validate(PatchCustomerSchema, { 'name': aboxFixtures.customer.name });

console.assert(patchErrs.length === 0);

const summary = jt.instantiate(CustomerSummarySchema, {
  'customerId': aboxFixtures.customer.customerId,
  'name': aboxFixtures.customer.name
}) as { 'customerId': string;
  'name': string };

console.assert(summary.customerId === aboxFixtures.customer.customerId);
console.assert(summary.name === aboxFixtures.customer.name);

console.log('patch validation errors:', patchErrs.length);
console.log('summary customerId:', summary.customerId);
console.log('summary name:', summary.name);
Output
Press Execute to run this example against the real library.

The full set of combinators (extend, omit, required, intersection, discriminatedUnion) is covered in Composition.

Serialize back to wire form

dump() walks the validation graph and applies any registered Transform encoders. It is the Pydantic model_dump() equivalent.

/**
 * dumpJson against the canonical Customer — Example
 *
 * `dumpJson` round-trips a registered Customer through the canonical
 * registry to its wire-form JSON. The Bastian-orders-Neverending-Story
 * fixture is the input.
 */

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

const customer = bookstoreEntities.instantiate(CustomerSchema.$id, aboxFixtures.customer);
const wire = bookstoreEntities.dumpJson(CustomerSchema.$id, customer);

console.assert(typeof wire === 'string' && wire.length > 0);
console.assert(wire.includes('Bastian Balthazar Bux'));

console.log('wire JSON:', wire);
Output
Press Execute to run this example against the real library.

Filtering options (exclude, include, excludeDefaults) are documented in Serialization.

Sub-path imports

Import only what you need. Every sub-path is tree-shakable.

// Everything
import type {
  Compose, JsonTology, Transform, Value
} from '../../../src/index.js';

// Value operations only (no validation graph or ontology)
import type {
  Changeset, Hash, Value as V
} from '../../../src/index.js';

// Schema registry and format validators
import type {
  FormatRegistry, SchemaRegistry
} from '../../../src/schema/index.js';

// Types and interfaces only (compile-time, no runtime cost)
import type { InferType } from '../../../src/types/index.js';
import type { LoggerInterface } from '../../../src/interfaces/index.js';

// InferType is generic — apply it to a sample schema to verify the import.
type SampleInferred = InferType<{ readonly 'type': 'string' }>;

// Reference every imported type in a type position to confirm each sub-path
// export resolves. The imports compiling at all is the real proof; consuming
// them as an optional parameter type needs no runtime value and no cast.
const _verifySubpathImports = (_proof?: [
  JsonTology, Compose, Transform, Value, V, Hash, Changeset,
  SchemaRegistry, FormatRegistry, SampleInferred, LoggerInterface
]): void => {
  return;
};

void _verifySubpathImports;

// All imports are compile-time only — log the sub-path names as documentation.
console.log('sub-paths available:', [
  'json-tology',
  'json-tology/value',
  'json-tology/schema',
  'json-tology/types',
  'json-tology/interfaces'
].join(', '));
Output
Press Execute to run this example against the real library.

What's in the box

FeatureMethod(s)Mode
Type inferenceInferType<T>, InferSchemaType<T, Root>Compile-time
Validationvalidate, is, subschemaAtRuntime
Coercion + defaultsinstantiateCompile-time + Runtime
Error viewsaggregate, reportRuntime
CompositionCompose.extend, pick, omit, partial, required, intersection, equivalent, discriminatedUnionCompile-time + Runtime
Value utilitiesOperations.clone, Hash.value, Value.diff, Operations.patch, value.cast, clean, convert, createRuntime
TransformsTransform.create, brand, chain, jt.encodeCompile-time + Runtime
Serializationdump, dumpJsonRuntime
Computed fieldsaddComputed, removeComputedRuntime
Cross-field invariantsaddInvariant, removeInvariantRuntime
MaterializationmaterializeCompile-time + Runtime
RDF/Ontology (advanced, opt-in)ontology, toQuads, fromQuads, toSchemaRuntime

Configuring JsonTology.create

The full option reference lives at Static helpers. Briefly, you supply baseIri (string), schemas (array as const), and optional dialect / format-registry / strict / coercion controls.

Next steps

TopicGuide
The running example domainBookstore Domain
TypeScript type inferenceType Inference
Validation and instantiationValidation
Composing schemasComposition
SerializationSerialization
Static helpers and create optionsStatic helpers

Released under the MIT License.