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
npm install json-tologyRequires 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);
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(', '));
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);
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);
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);
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);
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);
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);
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(', '));
What's in the box
| Feature | Method(s) | Mode |
|---|---|---|
| Type inference | InferType<T>, InferSchemaType<T, Root> | Compile-time |
| Validation | validate, is, subschemaAt | Runtime |
| Coercion + defaults | instantiate | Compile-time + Runtime |
| Error views | aggregate, report | Runtime |
| Composition | Compose.extend, pick, omit, partial, required, intersection, equivalent, discriminatedUnion | Compile-time + Runtime |
| Value utilities | Operations.clone, Hash.value, Value.diff, Operations.patch, value.cast, clean, convert, create | Runtime |
| Transforms | Transform.create, brand, chain, jt.encode | Compile-time + Runtime |
| Serialization | dump, dumpJson | Runtime |
| Computed fields | addComputed, removeComputed | Runtime |
| Cross-field invariants | addInvariant, removeInvariant | Runtime |
| Materialization | materialize | Compile-time + Runtime |
| RDF/Ontology (advanced, opt-in) | ontology, toQuads, fromQuads, toSchema | Runtime |
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
| Topic | Guide |
|---|---|
| The running example domain | Bookstore Domain |
| TypeScript type inference | Type Inference |
| Validation and instantiation | Validation |
| Composing schemas | Composition |
| Serialization | Serialization |
| Static helpers and create options | Static helpers |