Benchmarks
Every scenario on this page shows three things together:
- The source code: the actual
.bench.tsfile that runs in Node, embedded via vitepress source includes. Click through to GitHub or use the file path to inspect locally. - Browser runner:
▶ Run in browserbutton. json-tology is loaded directly from this repo'ssrc/(HEAD, not a published version) via a Vite alias, so the browser runs the same code you're reading on this page. Peer comparators load from theiresm.shCDN entries on demand. Numbers are directional; browser timing variance is high. - Latest Node run: the canonical results table for that scenario, regenerated by
npm run bench:report.
The runnable source lives at examples/docs/benchmarks/. The bench harness is harness.ts: performance.now() timing, fixed warmup + iteration count per scenario, no third-party bench framework.
Environment
The Node tables below were generated on the developer machine. Run npm run bench:report against a fresh clone to regenerate against your hardware.
git clone https://github.com/Studnicky/json-tology.git
cd json-tology
npm install
npm run bench:reportThe runner writes examples/docs/benchmarks/results/latest.md (consolidated) and one .md fragment per scenario under examples/docs/benchmarks/results/scenarios/ (what the per-scenario tables on this page include).
For deeper investigation:
npm run bench # human-readable console output, no markdown
npm run bench:flame # Node's built-in CPU profiler, writes a .cpuprofile under .flame/Open the generated .flame/flame.cpuprofile in Chrome DevTools' Performance panel (Load profile) or VS Code's built-in profile viewer.
How to read each table
- ops/s: operations per second after warmup. Higher is better.
- ns/op: nanoseconds per operation. Lower is better.
- vs json-tology / json-tology vs this: ratio against the json-tology row in the same table.
- - (dash): library does not run this scenario, or hit a load/setup error (hover the row in the browser table for detail).
Validation
registry.validate against AJV validate, TypeBox TypeCompiler.Check, Zod safeParse, Valibot safeParse, ArkType, io-ts decode.
Source:
/**
* Validation benchmarks: json-tology vs TypeBox vs AJV vs Zod vs Valibot vs io-ts.
*/
import { TypeCompiler } from '@sinclair/typebox/compiler';
import { safeParse } from 'valibot';
import { SchemaRegistry } from '../../../src/modules/registry/SchemaRegistry.js';
import {
bench, type BenchResult, section
} from './harness.js';
import {
ajvValidateOrder, ajvValidateReview,
bookstoreBenchSchemas,
OrderSchemaIoTs, OrderSchemaTypebox, OrderSchemaValibot, OrderSchemaZod,
orderValid,
reviewInvalid, ReviewSchemaIoTs, ReviewSchemaTypebox, ReviewSchemaValibot,
ReviewSchemaZod, reviewValid
} from './fixtures.js';
import {
OrderSchema, ReviewSchema
} from '../bookstore/index.js';
export function runValidateBench(): BenchResult[] {
const results: BenchResult[] = [];
const registry = new SchemaRegistry({ 'enableStrictGraph': false });
for (const schema of bookstoreBenchSchemas) {
registry.set(schema as Record<string, unknown>);
}
const tbReview = TypeCompiler.Compile(ReviewSchemaTypebox);
const tbOrder = TypeCompiler.Compile(OrderSchemaTypebox);
// Force lazy compilation
registry.validate(ReviewSchema.$id, reviewValid);
registry.validate(OrderSchema.$id, orderValid);
section('Validation — Review (flat object, valid data)');
results.push(bench('review valid', 'json-tology', () => {
return registry.validate(ReviewSchema.$id, reviewValid);
}));
results.push(bench('review valid', 'typebox', () => {
return tbReview.Check(reviewValid);
}));
results.push(bench('review valid', 'ajv', () => {
return ajvValidateReview(reviewValid);
}));
results.push(bench('review valid', 'zod', () => {
return ReviewSchemaZod.safeParse(reviewValid);
}));
results.push(bench('review valid', 'valibot', () => {
return safeParse(ReviewSchemaValibot, reviewValid);
}));
results.push(bench('review valid', 'io-ts', () => {
return ReviewSchemaIoTs.decode(reviewValid);
}));
section('Validation — Review (invalid data, error collection)');
results.push(bench('review invalid', 'json-tology', () => {
return registry.validate(ReviewSchema.$id, reviewInvalid);
}));
results.push(bench('review invalid', 'typebox', () => {
return [...tbReview.Errors(reviewInvalid)];
}));
results.push(bench('review invalid', 'ajv', () => {
return ajvValidateReview(reviewInvalid);
}));
results.push(bench('review invalid', 'zod', () => {
return ReviewSchemaZod.safeParse(reviewInvalid);
}));
results.push(bench('review invalid', 'valibot', () => {
return safeParse(ReviewSchemaValibot, reviewInvalid);
}));
results.push(bench('review invalid', 'io-ts', () => {
return ReviewSchemaIoTs.decode(reviewInvalid);
}));
section('Validation — Order (nested $ref graph, valid data)');
results.push(bench('order valid', 'json-tology', () => {
return registry.validate(OrderSchema.$id, orderValid);
}));
results.push(bench('order valid', 'typebox', () => {
return tbOrder.Check(orderValid);
}));
results.push(bench('order valid', 'ajv', () => {
return ajvValidateOrder(orderValid);
}));
results.push(bench('order valid', 'zod', () => {
return OrderSchemaZod.safeParse(orderValid);
}));
results.push(bench('order valid', 'valibot', () => {
return safeParse(OrderSchemaValibot, orderValid);
}));
results.push(bench('order valid', 'io-ts', () => {
return OrderSchemaIoTs.decode(orderValid);
}));
return results;
}simple valid
Flat 3-property object that satisfies the schema.
| Library | ops/s | ns/op | json-tology vs this |
|---|---|---|---|
| json-tology | 2,565,821 | 390 | - |
| ajv | 20,919,587 | 48 | 8.15x slower |
| io-ts | 4,837,949 | 207 | 1.89x slower |
| typebox | 28,193,833 | 35 | 10.99x slower |
| valibot | 3,243,875 | 308 | 1.26x slower |
| zod | 4,060,495 | 246 | 1.58x slower |
simple invalid
Flat 3-property object that fails every constraint, measuring error-collection cost.
| Library | ops/s | ns/op | json-tology vs this |
|---|---|---|---|
| json-tology | 2,549,259 | 392 | - |
| ajv | 15,194,876 | 66 | 5.96x slower |
| io-ts | 6,013,968 | 166 | 2.36x slower |
| typebox | 797,874 | 1253 | 3.20x faster |
| valibot | 1,451,717 | 689 | 1.76x faster |
| zod | 123,323 | 8109 | 20.67x faster |
nested valid
Nested object with $ref sub-schemas; the cross-schema reference case.
| Library | ops/s | ns/op | json-tology vs this |
|---|---|---|---|
| json-tology | 537,413 | 1861 | - |
| ajv | 3,344,957 | 299 | 6.22x slower |
| io-ts | 1,630,893 | 613 | 3.03x slower |
| typebox | 6,338,324 | 158 | 11.79x slower |
| valibot | 889,277 | 1125 | 1.65x slower |
| zod | 1,343,359 | 744 | 2.50x slower |
Instantiation
registry.instantiate (no coercion) against TypeBox Value.Parse, Zod parse, Valibot parse, ArkType, io-ts decode.
Source:
/**
* Instantiate benchmarks: json-tology's primary parse-and-normalize API
* vs Zod .parse / TypeBox Value.Parse / Valibot parse / io-ts decode.
*
* Distinct from coerce.bench.ts: this measures the typed entry point
* (registry.instantiate / facade .instantiate), with castTypes off (no coercion),
* representing the steady-state happy path most users hit.
*/
import { Value } from '@sinclair/typebox/value';
import { FormatRegistry } from '@sinclair/typebox';
import { parse as vParse } from 'valibot';
import { SchemaRegistry } from '../../../src/modules/registry/SchemaRegistry.js';
FormatRegistry.Set('email', (value) => {
return /^[^\s@]+@[^\s@][^\s.@]*\.[^\s@]+$/u.test(value);
});
FormatRegistry.Set('date-time', (value) => {
return !Number.isNaN(Date.parse(value));
});
FormatRegistry.Set('uuid', (value) => {
return /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/iu.test(value);
});
import {
bench, type BenchResult, section
} from './harness.js';
import {
bookstoreBenchSchemas,
OrderSchemaIoTs, OrderSchemaTypebox, OrderSchemaValibot, OrderSchemaZod,
orderValid,
ReviewSchemaIoTs, ReviewSchemaTypebox, ReviewSchemaValibot, ReviewSchemaZod,
reviewValid
} from './fixtures.js';
import {
OrderSchema, ReviewSchema
} from '../bookstore/index.js';
export function runInstantiateBench(): BenchResult[] {
const results: BenchResult[] = [];
const registry = new SchemaRegistry({ 'enableStrictGraph': false });
for (const schema of bookstoreBenchSchemas) {
registry.set(schema as Record<string, unknown>);
}
// Warm up
registry.instantiate(ReviewSchema, reviewValid);
registry.instantiate(OrderSchema, orderValid);
section('instantiate — Review (parse + normalize, no coercion)');
results.push(bench('instantiate review', 'json-tology', () => {
return registry.instantiate(ReviewSchema, reviewValid);
}));
results.push(bench('instantiate review', 'typebox', () => {
return Value.Parse(ReviewSchemaTypebox, reviewValid);
}));
results.push(bench('instantiate review', 'zod', () => {
return ReviewSchemaZod.parse(reviewValid);
}));
results.push(bench('instantiate review', 'valibot', () => {
return vParse(ReviewSchemaValibot, reviewValid);
}));
results.push(bench('instantiate review', 'io-ts', () => {
return ReviewSchemaIoTs.decode(reviewValid);
}));
section('instantiate — Order (parse + normalize, no coercion)');
results.push(bench('instantiate order', 'json-tology', () => {
return registry.instantiate(OrderSchema, orderValid);
}));
results.push(bench('instantiate order', 'typebox', () => {
return Value.Parse(OrderSchemaTypebox, orderValid);
}));
results.push(bench('instantiate order', 'zod', () => {
return OrderSchemaZod.parse(orderValid);
}));
results.push(bench('instantiate order', 'valibot', () => {
return vParse(OrderSchemaValibot, orderValid);
}));
results.push(bench('instantiate order', 'io-ts', () => {
return OrderSchemaIoTs.decode(orderValid);
}));
return results;
}instantiate simple
Parse + normalize a flat object; no coercion.
| Library | ops/s | ns/op | json-tology vs this |
|---|---|---|---|
| json-tology | 519,873 | 1924 | - |
| io-ts | 4,664,596 | 214 | 8.97x slower |
| typebox | 788,436 | 1268 | 1.52x slower |
| valibot | 3,059,153 | 327 | 5.88x slower |
| zod | 3,484,720 | 287 | 6.70x slower |
instantiate nested
Parse + normalize a nested object.
| Library | ops/s | ns/op | json-tology vs this |
|---|---|---|---|
| json-tology | 148,704 | 6725 | - |
| io-ts | 1,594,716 | 627 | 10.72x slower |
| typebox | 135,673 | 7371 | 1.10x faster |
| valibot | 908,744 | 1100 | 6.11x slower |
| zod | 1,436,523 | 696 | 9.66x slower |
Coerce
registry.instantiate with enableTypeCast: true against Zod's z.coerce.*.
Source:
/**
* Coerce pipeline benchmarks vs TypeBox Value.Parse vs Zod .parse vs Valibot parse vs io-ts decode.
*/
import { Value } from '@sinclair/typebox/value';
import {
FormatRegistry, Type
} from '@sinclair/typebox';
import { parse as vParse } from 'valibot';
import { SchemaRegistry } from '../../../src/modules/registry/SchemaRegistry.js';
// Register formats for TypeBox (it ships without built-in formats)
FormatRegistry.Set('email', (value) => {
return /^[^\s@]+@[^\s@][^\s.@]*\.[^\s@]+$/u.test(value);
});
FormatRegistry.Set('date-time', (value) => {
return !Number.isNaN(Date.parse(value));
});
FormatRegistry.Set('uuid', (value) => {
return /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/iu.test(value);
});
import {
bench, type BenchResult, section
} from './harness.js';
import {
bookstoreBenchSchemas,
customerDefaultsInput,
ReviewSchemaIoTs,
ReviewSchemaTypebox, ReviewSchemaValibot, ReviewSchemaZod, reviewValid
} from './fixtures.js';
import {
CustomerSchema, ReviewSchema
} from '../bookstore/index.js';
export function runCoerceBench(): BenchResult[] {
const results: BenchResult[] = [];
const registry = new SchemaRegistry({ 'enableStrictGraph': false });
for (const schema of bookstoreBenchSchemas) {
registry.set(schema as Record<string, unknown>);
}
// Warm up
registry.instantiate(ReviewSchema, reviewValid);
registry.instantiate(CustomerSchema, customerDefaultsInput);
section('coerce — already-valid Review (no coercion needed)');
results.push(bench('coerce review valid', 'json-tology', () => {
return registry.instantiate(ReviewSchema, reviewValid);
}));
results.push(bench('coerce review valid', 'typebox', () => {
return Value.Parse(ReviewSchemaTypebox, reviewValid);
}));
results.push(bench('coerce review valid', 'zod', () => {
return ReviewSchemaZod.parse(reviewValid);
}));
results.push(bench('coerce review valid', 'valibot', () => {
return vParse(ReviewSchemaValibot, reviewValid);
}));
results.push(bench('coerce review valid', 'io-ts', () => {
return ReviewSchemaIoTs.decode(reviewValid);
}));
section('coerce — Customer with defaults application (addresses → [])');
results.push(bench('coerce customer defaults', 'json-tology', () => {
return registry.instantiate(CustomerSchema, customerDefaultsInput);
}));
// TypeBox mirror of the bookstore Customer wire shape so the defaults
// application can be compared head-to-head with json-tology.
const CustomerWithDefaultsTb = Type.Object({
'addresses': Type.Array(
Type.Object({
'city': Type.String({
'maxLength': 100,
'minLength': 1
}),
'country': Type.String({ 'pattern': '^[A-Z]{2}$' }),
'postalCode': Type.String({
'maxLength': 12,
'minLength': 3
}),
'street': Type.String({
'maxLength': 200,
'minLength': 1
})
}),
{ 'default': [] }
),
'customerId': Type.String({ 'format': 'uuid' }),
'email': Type.String({ 'format': 'email' }),
'name': Type.String({
'maxLength': 200,
'minLength': 1
})
});
results.push(bench('coerce customer defaults', 'typebox', () => {
return Value.Parse(CustomerWithDefaultsTb, customerDefaultsInput);
}));
return results;
}coerce valid
Already-valid data through the coerce path.
| Library | ops/s | ns/op | json-tology vs this |
|---|---|---|---|
| json-tology | 537,935 | 1859 | - |
| io-ts | 4,839,520 | 207 | 9.00x slower |
| typebox | 710,480 | 1407 | 1.32x slower |
| valibot | 3,030,892 | 330 | 5.63x slower |
| zod | 3,944,605 | 254 | 7.33x slower |
coerce defaults
Apply default values during instantiate.
| Library | ops/s | ns/op | json-tology vs this |
|---|---|---|---|
| json-tology | 494,316 | 2023 | - |
| io-ts | N/A | N/A | N/A |
| typebox | 890,559 | 1123 | 1.80x slower |
| valibot | N/A | N/A | N/A |
| zod | N/A | N/A | N/A |
Value operations
Operations.clone, Value.diff, registry.clean, registry.convert against structuredClone.
Source:
/**
* Value operation benchmarks: clean, convert, diff, clone vs TypeBox equivalents.
*/
import { TypeBoxValue } from './typebox.js';
import { FormatRegistry } from '@sinclair/typebox';
// Register formats for TypeBox
FormatRegistry.Set('email', (value) => {
return /^[^\s@]+@[^\s@][^\s.@]*\.[^\s@]+$/u.test(value);
});
FormatRegistry.Set('date-time', (value) => {
return !Number.isNaN(Date.parse(value));
});
FormatRegistry.Set('uuid', (value) => {
return /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/iu.test(value);
});
import { Operations } from '../../../src/modules/data/Operations.js';
import { Value } from '../../../src/modules/data/Value.js';
import { SchemaRegistry } from '../../../src/modules/registry/SchemaRegistry.js';
import {
bench, type BenchResult, section
} from './harness.js';
import {
bookstoreBenchSchemas,
OrderSchemaTypebox, orderValid,
reviewCoercible, ReviewSchemaTypebox, reviewValid
} from './fixtures.js';
import {
OrderSchema, ReviewSchema
} from '../bookstore/index.js';
const dirtyReview = {
...reviewValid,
'extra1': 'junk',
'extra2': 42,
'extra3': true
};
const dirtyOrder = {
...orderValid,
'extraTop': 'remove me',
'orderTotal': {
...orderValid.orderTotal,
'hackField': 'bad'
},
'shippingAddress': {
...orderValid.shippingAddress,
'extra': 'x'
}
};
export function runValueOpsBench(): BenchResult[] {
const results: BenchResult[] = [];
const registry = new SchemaRegistry({ 'enableStrictGraph': false });
for (const schema of bookstoreBenchSchemas) {
registry.set(schema as Record<string, unknown>);
}
// Warm up engines
registry.validate(ReviewSchema.$id, reviewValid);
registry.validate(OrderSchema.$id, orderValid);
// ---------------------------------------------------------------------------
section('clean — strip unknown properties');
const cleanReviewResult = bench('clean review', 'json-tology', () => {
return registry.clean(ReviewSchema.$id, dirtyReview);
});
results.push(cleanReviewResult);
const cleanReviewTbResult = bench('clean review', 'typebox', () => {
return TypeBoxValue.clean(ReviewSchemaTypebox, structuredClone(dirtyReview));
});
results.push(cleanReviewTbResult);
const cleanOrderResult = bench('clean order', 'json-tology', () => {
return registry.clean(OrderSchema.$id, dirtyOrder);
});
results.push(cleanOrderResult);
const cleanOrderTbResult = bench('clean order', 'typebox', () => {
return TypeBoxValue.clean(OrderSchemaTypebox, structuredClone(dirtyOrder));
});
results.push(cleanOrderTbResult);
// ---------------------------------------------------------------------------
section('convert — type coercion (no defaults)');
const convertReviewResult = bench('convert review', 'json-tology', () => {
return registry.convert(ReviewSchema.$id, reviewCoercible);
});
results.push(convertReviewResult);
const convertReviewTbResult = bench('convert review', 'typebox', () => {
return TypeBoxValue.convert(ReviewSchemaTypebox, reviewCoercible);
});
results.push(convertReviewTbResult);
// ---------------------------------------------------------------------------
section('clone — deep clone');
const cloneOrderResult = bench('clone order', 'json-tology', () => {
return Operations.clone(orderValid);
});
results.push(cloneOrderResult);
const cloneStructuredResult = bench('clone order', 'structuredClone', () => {
return structuredClone(orderValid);
});
results.push(cloneStructuredResult);
// ---------------------------------------------------------------------------
section('diff — structural diff');
const orderModified = {
...orderValid,
'orderTotal': {
...orderValid.orderTotal,
'amount': 999
},
'shippingAddress': {
...orderValid.shippingAddress,
'city': 'Berlin'
}
};
const diffOrderResult = bench('diff order', 'json-tology', () => {
return Value.diff(orderValid, orderModified);
});
results.push(diffOrderResult);
const diffOrderTbResult = bench('diff order', 'typebox', () => {
return [...TypeBoxValue.diff(orderValid, orderModified)];
});
results.push(diffOrderTbResult);
return results;
}clean simple
Strip unknown keys from a flat object.
| Library | ops/s | ns/op | json-tology vs this |
|---|---|---|---|
| json-tology | 755,306 | 1324 | - |
| structuredClone | N/A | N/A | N/A |
| typebox | 938,105 | 1066 | 1.24x slower |
clean nested
Strip unknown keys from a nested object.
| Library | ops/s | ns/op | json-tology vs this |
|---|---|---|---|
| json-tology | 212,292 | 4710 | - |
| structuredClone | N/A | N/A | N/A |
| typebox | 325,328 | 3074 | 1.53x slower |
convert simple
String → number / boolean coercion only.
| Library | ops/s | ns/op | json-tology vs this |
|---|---|---|---|
| json-tology | 930,934 | 1074 | - |
| structuredClone | N/A | N/A | N/A |
| typebox | 5,828,963 | 172 | 6.26x slower |
clone nested
Deep-clone a nested object.
| Library | ops/s | ns/op | json-tology vs this |
|---|---|---|---|
| json-tology | 542,059 | 1845 | - |
| structuredClone | 550,785 | 1816 | 1.02x slower |
| typebox | N/A | N/A | N/A |
diff nested
Compute a changeset between two nested objects.
| Library | ops/s | ns/op | json-tology vs this |
|---|---|---|---|
| json-tology | 2,003,610 | 499 | - |
| structuredClone | N/A | N/A | N/A |
| typebox | 145,663 | 6865 | 13.76x faster |
Transforms
Transform.create decode + facade encode against TypeBox Value.Decode/Encode, Zod .transform, io-ts custom codec decode/encode. Node-only: the Transform encoder/decoder takes a schema with a registered codec (decoders + encoders keyword set); a one-button click can't faithfully reproduce the registry-warmed state.
Source:
/**
* Transform decode/encode benchmarks.
*
* Compares json-tology's Transform.create attached decoders against:
* - Zod's .transform() / .pipe() round-trip
* - TypeBox Value.Decode / Value.Encode
* - io-ts custom codec with .decode() / .encode()
*
* Valibot has no symmetric decode/encode primitive at this surface, so it is
* not included for the round-trip case (would be unfair).
*/
import { Type } from '@sinclair/typebox';
import { Value } from '@sinclair/typebox/value';
import {
Type as IotType,
type Validation as IotValidation
} from 'io-ts';
import { z } from 'zod';
import { JsonTology } from '../../../src/JsonTology.js';
import { Transform } from '../../../src/modules/transform/Transform.js';
import {
bench, type BenchResult, section
} from './harness.js';
// ---------------------------------------------------------------------------
// Schema: ISO date-time string ↔ Date
// ---------------------------------------------------------------------------
const DateStringSchema = {
'$id': 'DateString',
'format': 'date-time',
'type': 'string'
} as const;
const DateSchemaJt = Transform.create(DateStringSchema, {
'decode': (input: string) => {
// Normalize: parse and re-emit as canonical ISO string
return new Date(input).toISOString();
},
'encode': (canonical: string) => {
// Encode reversal: return canonical ISO string to wire
return canonical;
}
});
const DateSchemaZod = z.string().datetime()
.transform((input) => {
return new Date(input).toISOString();
});
const DateSchemaTypebox = Type.Transform(Type.String({ 'format': 'date-time' }))
.Decode((input) => {
return new Date(input).toISOString();
})
.Encode((canonical: string) => {
return canonical;
});
const DateSchemaIoTs = new IotType<string, string, unknown>(
'DateFromIsoString',
(input): input is string => {
return typeof input === 'string';
},
(input, context): IotValidation<string> => {
if (typeof input !== 'string') {
return {
'_tag': 'Left',
'left': [{
'context': context,
'value': input
}]
};
}
const ms = Date.parse(input);
if (Number.isNaN(ms)) {
return {
'_tag': 'Left',
'left': [{
'context': context,
'value': input
}]
};
}
return {
'_tag': 'Right',
'right': new Date(ms).toISOString()
};
},
(output) => {
return output;
}
);
const wireValue = '2024-01-15T10:30:00.000Z';
const canonicalValue = new Date(wireValue).toISOString();
export function runTransformBench(): BenchResult[] {
const results: BenchResult[] = [];
// doc example with synthetic fixture schemas (strict-graph default does not throw because no inline duplicates)
const jt = JsonTology.create({
'baseIri': 'urn:bench:transform',
'schemas': [DateSchemaJt]
});
// Warm up
jt.instantiate(DateSchemaJt, wireValue);
Value.Decode(DateSchemaTypebox, wireValue);
DateSchemaZod.parse(wireValue);
DateSchemaIoTs.decode(wireValue);
DateSchemaIoTs.encode(canonicalValue);
section('transform — decode wire → canonical (string normalize)');
results.push(bench('decode date', 'json-tology', () => {
return jt.instantiate(DateSchemaJt, wireValue);
}));
results.push(bench('decode date', 'typebox', () => {
return Value.Decode(DateSchemaTypebox, wireValue);
}));
results.push(bench('decode date', 'zod', () => {
return DateSchemaZod.parse(wireValue);
}));
results.push(bench('decode date', 'io-ts', () => {
return DateSchemaIoTs.decode(wireValue);
}));
section('transform — encode canonical → wire (string reversal)');
results.push(bench('encode date', 'json-tology', () => {
return jt.encode(DateSchemaJt, canonicalValue);
}));
results.push(bench('encode date', 'typebox', () => {
// interop: TypeBox's Transform Encode expects the statically-decoded shape.
// canonicalValue is a `string`, which matches it, so it is accepted directly.
return Value.Encode(DateSchemaTypebox, canonicalValue);
}));
results.push(bench('encode date', 'io-ts', () => {
return DateSchemaIoTs.encode(canonicalValue);
}));
// Zod 4 supports .pipe back; using zod codec round-trip via toJSON would be
// an unrelated comparison. Encode is unique surface area for json-tology
// and TypeBox in this fixture.
return results;
}decode date
Decode an ISO-8601 string into a Date instance.
| Library | ops/s | ns/op | json-tology vs this |
|---|---|---|---|
| json-tology | 151,879 | 6584 | - |
| io-ts | 2,086,400 | 479 | 13.74x slower |
| typebox | 1,419,840 | 704 | 9.35x slower |
| zod | 1,736,460 | 576 | 11.43x slower |
encode date
Encode a Date instance back to a wire-format ISO-8601 string.
| Library | ops/s | ns/op | json-tology vs this |
|---|---|---|---|
| json-tology | 122,430,190 | 8 | - |
| io-ts | 259,993,500 | 4 | 2.12x slower |
| typebox | 4,968,029 | 201 | 24.64x faster |
| zod | N/A | N/A | N/A |
Composition
Compose.extend / intersection / discriminatedUnion against TypeBox Type.Composite / Intersect / Union, Zod .extend / intersection / discriminatedUnion, Valibot variant. Node-only: composition timing is setup-dominated. The cold path includes graph construction + subschema linking + JIT compilation; the warm path measures one compiled validator. The mix is library-specific and produces noisy in-browser numbers.
Source:
/**
* Composition benchmarks: extend / intersection / discriminatedUnion.
*
* Authors a derived schema and runs first-validate against it.
* Compares json-tology Compose.* against Zod's .extend / .merge / z.discriminatedUnion
* and TypeBox Type.Composite / Type.Union.
*/
import { Type } from '@sinclair/typebox';
import { TypeCompiler } from '@sinclair/typebox/compiler';
import { z } from 'zod';
import {
literal as vLiteral,
number as vNumber,
object as vObject,
safeParse as vSafeParse,
variant as vVariant
} from 'valibot';
import { Compose } from '../../../src/modules/composition/Compose.js';
import { SchemaRegistry } from '../../../src/modules/registry/SchemaRegistry.js';
import {
bench, type BenchResult, section
} from './harness.js';
// ---------------------------------------------------------------------------
// Base schemas (book domain)
// ---------------------------------------------------------------------------
const BaseBookJt = {
'$id': 'urn:bench:Book',
'properties': {
'isbn': { 'type': 'string' },
'title': { 'type': 'string' }
},
'required': [
'isbn',
'title'
],
'type': 'object'
} as const;
const BaseBookTb = Type.Object({
'isbn': Type.String(),
'title': Type.String()
});
const BaseBookZod = z.object({
'isbn': z.string(),
'title': z.string()
});
const validBook = {
'isbn': '978-0-123-45678-9',
'title': 'The Pragmatic Programmer'
};
// Discriminated union variants
const CircleJt = {
'$id': 'urn:bench:Circle',
'properties': {
'kind': {
'const': 'circle',
'type': 'string'
},
'radius': { 'type': 'number' }
},
'required': [
'kind',
'radius'
],
'type': 'object'
} as const;
const RectJt = {
'$id': 'urn:bench:Rect',
'properties': {
'height': { 'type': 'number' },
'kind': {
'const': 'rect',
'type': 'string'
},
'width': { 'type': 'number' }
},
'required': [
'kind',
'width',
'height'
],
'type': 'object'
} as const;
const validCircle = {
'kind': 'circle',
'radius': 5
};
const CircleZod = z.object({
'kind': z.literal('circle'),
'radius': z.number()
});
const RectZod = z.object({
'height': z.number(),
'kind': z.literal('rect'),
'width': z.number()
});
const ShapeZod = z.discriminatedUnion('kind', [
CircleZod,
RectZod
]);
const CircleTb = Type.Object({
'kind': Type.Literal('circle'),
'radius': Type.Number()
});
const RectTb = Type.Object({
'height': Type.Number(),
'kind': Type.Literal('rect'),
'width': Type.Number()
});
const ShapeTb = Type.Union([
CircleTb,
RectTb
]);
const CircleVb = vObject({
'kind': vLiteral('circle'),
'radius': vNumber()
});
const RectVb = vObject({
'height': vNumber(),
'kind': vLiteral('rect'),
'width': vNumber()
});
const ShapeVb = vVariant('kind', [
CircleVb,
RectVb
]);
export function runComposeBench(): BenchResult[] {
const results: BenchResult[] = [];
section('compose — extend (build derived schema, no validation)');
results.push(bench('extend build', 'json-tology', () => {
return Compose.extend(
BaseBookJt,
{ 'properties': { 'pages': { 'type': 'integer' } } } as const,
'urn:bench:ExtBook'
);
}));
results.push(bench('extend build', 'typebox', () => {
return Type.Composite([
BaseBookTb,
Type.Object({ 'pages': Type.Integer() })
]);
}));
results.push(bench('extend build', 'zod', () => {
return BaseBookZod.extend({ 'pages': z.number().int() });
}));
section('compose — extend + validate (warm, build outside loop)');
// warm: register once outside the timing loop — measures steady-state validate, not registration + compile
const ExtBookJt = Compose.extend(
BaseBookJt,
{ 'properties': { 'pages': { 'type': 'integer' } } } as const,
'urn:bench:ExtBookJt'
);
const extReg = new SchemaRegistry({ 'enableStrictGraph': false });
extReg.set(BaseBookJt);
extReg.set(ExtBookJt);
const extBookId = (ExtBookJt as { '$id': string }).$id;
const extBookValid = {
...validBook,
'pages': 200
};
const extTbCompiled = TypeCompiler.Compile(Type.Composite([
BaseBookTb,
Type.Object({ 'pages': Type.Integer() })
]));
const extZodSchema = BaseBookZod.extend({ 'pages': z.number().int() });
results.push(bench('extend + validate', 'json-tology', () => {
return extReg.validate(extBookId, extBookValid);
}));
results.push(bench('extend + validate', 'typebox', () => {
return extTbCompiled.Check(extBookValid);
}));
results.push(bench('extend + validate', 'zod', () => {
return extZodSchema.safeParse(extBookValid);
}));
section('compose — discriminatedUnion validation (warm)');
const ShapeJt = Compose.discriminatedUnion(
'kind',
[
CircleJt,
RectJt
] as const,
'urn:bench:Shape'
);
const reg = new SchemaRegistry({ 'enableStrictGraph': false });
reg.set(CircleJt);
reg.set(RectJt);
reg.set({ ...ShapeJt });
reg.validate((ShapeJt as { '$id': string }).$id, validCircle);
const ShapeTbCompiled = TypeCompiler.Compile(ShapeTb);
ShapeTbCompiled.Check(validCircle);
ShapeZod.safeParse(validCircle);
vSafeParse(ShapeVb, validCircle);
results.push(bench('discriminated union', 'json-tology', () => {
return reg.validate((ShapeJt as { '$id': string }).$id, validCircle);
}));
results.push(bench('discriminated union', 'typebox', () => {
return ShapeTbCompiled.Check(validCircle);
}));
results.push(bench('discriminated union', 'zod', () => {
return ShapeZod.safeParse(validCircle);
}));
results.push(bench('discriminated union', 'valibot', () => {
return vSafeParse(ShapeVb, validCircle);
}));
section('compose — intersection (warm, build outside loop)');
const Tagged = {
'$id': 'urn:bench:Tagged',
'properties': {
'tags': {
'items': { 'type': 'string' },
'type': 'array'
}
},
'required': ['tags'],
'type': 'object'
} as const;
const TaggedTb = Type.Object({ 'tags': Type.Array(Type.String()) });
const TaggedZod = z.object({ 'tags': z.array(z.string()) });
// warm: register once outside the timing loop — measures steady-state validate, not registration + compile
const BookTaggedInter = Compose.intersection(
[
BaseBookJt,
Tagged
],
'urn:bench:BookTagged'
);
const subReg = new SchemaRegistry({ 'enableStrictGraph': false });
subReg.set(BaseBookJt);
subReg.set(Tagged);
subReg.set({ ...BookTaggedInter });
const bookTaggedId = (BookTaggedInter as { '$id': string }).$id;
const bookTaggedValid = {
...validBook,
'tags': ['a']
};
const interTbCompiled = TypeCompiler.Compile(Type.Intersect([
BaseBookTb,
TaggedTb
]));
const interZodSchema = z.intersection(BaseBookZod, TaggedZod);
results.push(bench('intersection', 'json-tology', () => {
return subReg.validate(bookTaggedId, bookTaggedValid);
}));
results.push(bench('intersection', 'typebox', () => {
return interTbCompiled.Check(bookTaggedValid);
}));
results.push(bench('intersection', 'zod', () => {
return interZodSchema.safeParse(bookTaggedValid);
}));
return results;
}extend build
Build a derived schema via Compose.extend.
| Library | ops/s | ns/op | json-tology vs this |
|---|---|---|---|
| json-tology | 12,504,494 | 80 | - |
| typebox | 1,503,815 | 665 | 8.32x faster |
| valibot | N/A | N/A | N/A |
| zod | 79,170 | 12631 | 157.94x faster |
extend + validate
Extend + register + run one validation.
| Library | ops/s | ns/op | json-tology vs this |
|---|---|---|---|
| json-tology | 1,367,193 | 731 | - |
| typebox | 158,373,943 | 6 | 115.84x slower |
| valibot | N/A | N/A | N/A |
| zod | 11,624,302 | 86 | 8.50x slower |
discriminated union
Validate against a discriminated union of variants.
| Library | ops/s | ns/op | json-tology vs this |
|---|---|---|---|
| json-tology | 1,859,086 | 538 | - |
| typebox | 143,377,809 | 7 | 77.12x slower |
| valibot | 6,329,832 | 158 | 3.40x slower |
| zod | 18,277,777 | 55 | 9.83x slower |
intersection
Build an intersection schema, register the parts and the merged form, validate one instance.
| Library | ops/s | ns/op | json-tology vs this |
|---|---|---|---|
| json-tology | 1,443,698 | 693 | - |
| typebox | 52,746,093 | 19 | 36.54x slower |
| valibot | N/A | N/A | N/A |
| zod | 4,824,964 | 207 | 3.34x slower |
Serialization
dump, dumpJson, facade encode against JSON.stringify and structuredClone.
Source:
/**
* Serialization benchmarks: dump / dumpJson vs JSON.stringify and TypeBox encoders.
*
* dump applies registered Transform encoders and projects the validated value
* back to wire form. We compare against:
* - hand-written JSON.stringify (the trivial baseline)
* - TypeBox Value.Encode (its closest analog when transforms are attached)
*
* For schemas without transforms, dump should approach JSON.stringify cost.
* dumpJson is dump + JSON.stringify in one pass.
*/
import { Type } from '@sinclair/typebox';
import { Value } from '@sinclair/typebox/value';
import { JsonTology } from '../../../src/JsonTology.js';
import { Transform } from '../../../src/modules/transform/Transform.js';
import {
bench, type BenchResult, section
} from './harness.js';
import {
bookstoreBenchSchemas, OrderSchemaTypebox, orderValid
} from './fixtures.js';
import { OrderSchema } from '../bookstore/index.js';
// ---------------------------------------------------------------------------
// Schema with a transform — exercises the encode path
// ---------------------------------------------------------------------------
const EventSchemaJt = Transform.create(
{
'$id': 'urn:bench:Event',
'properties': {
'at': {
'format': 'date-time',
'type': 'string'
},
'name': { 'type': 'string' }
},
'required': [
'at',
'name'
],
'type': 'object'
} as const,
{
'decode': (raw: { 'at': string;
'name': string }) => {
// Normalize: parse date and re-emit as canonical ISO string
return {
'at': new Date(raw.at).toISOString(),
'name': raw.name
};
},
'encode': (canonical: { 'at': string;
'name': string }) => {
// Encode reversal: return to wire form
return {
'at': canonical.at,
'name': canonical.name
};
}
}
);
const EventSchemaTb = Type.Transform(Type.Object({
'at': Type.String({ 'format': 'date-time' }),
'name': Type.String()
}))
.Decode((raw) => {
return {
'at': new Date(raw.at).toISOString(),
'name': raw.name
};
})
.Encode((canonical: { 'at': string;
'name': string }) => {
return {
'at': canonical.at,
'name': canonical.name
};
});
const canonicalEvent = {
'at': new Date('2024-06-01T12:00:00.000Z').toISOString(),
'name': 'Launch'
};
export function runSerializeBench(): BenchResult[] {
const results: BenchResult[] = [];
// -------------------------------------------------------------------------
// dump — schema without transforms (compares to JSON.stringify baseline)
// -------------------------------------------------------------------------
const jt = JsonTology.create({
'baseIri': 'urn:bench:serialize',
'enableStrictGraph': false,
'schemas': [
...bookstoreBenchSchemas,
EventSchemaJt
]
});
// Instantiate raw fixture to get the branded order type required by dump/dumpJson
const orderInstantiated = jt.instantiate(OrderSchema, orderValid);
// Warm
jt.dump(OrderSchema, orderInstantiated);
jt.dumpJson(OrderSchema, orderInstantiated);
jt.encode(EventSchemaJt, canonicalEvent);
section('serialize — dump Order (validated → wire), no transforms');
results.push(bench('dump order', 'json-tology', () => {
return jt.dump(OrderSchema, orderInstantiated);
}));
results.push(bench('dump order', 'structuredClone', () => {
return structuredClone(orderInstantiated);
}));
results.push(bench('dump order', 'typebox', () => {
return Value.Encode(OrderSchemaTypebox, orderValid);
}));
section('serialize — dumpJson Order (validated → JSON string)');
results.push(bench('dumpJson order', 'json-tology', () => {
return jt.dumpJson(OrderSchema, orderInstantiated);
}));
results.push(bench('dumpJson order', 'JSON.stringify', () => {
return JSON.stringify(orderValid);
}));
section('serialize — encode canonical → wire (with transforms)');
results.push(bench('encode event', 'json-tology', () => {
return jt.encode(EventSchemaJt, canonicalEvent);
}));
results.push(bench('encode event', 'typebox', () => {
// interop: TypeBox's Transform Encode expects the statically-decoded shape.
// canonicalEvent matches it structurally ({ at: string; name: string }), so it is
// accepted directly — no cast needed.
return Value.Encode(EventSchemaTb, canonicalEvent);
}));
return results;
}dump nested
Serialize a nested object via the json-tology dump pipeline.
| Library | ops/s | ns/op | json-tology vs this |
|---|---|---|---|
| json-tology | 61,656 | 16219 | - |
| JSON.stringify | N/A | N/A | N/A |
| structuredClone | 540,782 | 1849 | 8.77x slower |
| typebox | 408,219 | 2450 | 6.62x slower |
dumpJson nested
Serialize a nested object to a JSON string.
| Library | ops/s | ns/op | json-tology vs this |
|---|---|---|---|
| json-tology | 84,055 | 11897 | - |
| JSON.stringify | 1,919,790 | 521 | 22.84x slower |
| structuredClone | N/A | N/A | N/A |
| typebox | N/A | N/A | N/A |
encode event
Encode an event-like record through the Transform encoder. Node-only (see Transforms note above).
| Library | ops/s | ns/op | json-tology vs this |
|---|---|---|---|
| json-tology | 49,048,652 | 20 | - |
| JSON.stringify | N/A | N/A | N/A |
| structuredClone | N/A | N/A | N/A |
| typebox | 1,978,029 | 506 | 24.80x faster |
Registry
Cold-vs-warm validate timing. The cold scenario measures the cost of registering and JIT-compiling a schema for the very first time, before any caches are warm. Node-only because browsers cannot fully discard module-level state between iterations.
Source:
/**
* Registry benchmarks: cold cache (first validate compiles) vs warm cache.
*
* Measures the cost of:
* - registering N schemas
* - first call against a fresh registry (pays compilation/JIT cost)
* - subsequent calls (warm path, the steady-state hot loop)
*
* No comparator does this exactly the same way, but the closest analogs are:
* - TypeBox: TypeCompiler.Compile + first Check
* - Zod: schema definition + first safeParse
* - Valibot: schema definition + first safeParse
*
* For "register N schemas + first validate", we measure end-to-end cold cost.
*/
import { TypeCompiler } from '@sinclair/typebox/compiler';
import { z } from 'zod';
import { safeParse } from 'valibot';
import { SchemaRegistry } from '../../../src/modules/registry/SchemaRegistry.js';
import {
bench, type BenchResult, section
} from './harness.js';
import {
bookstoreBenchSchemas,
OrderSchemaTypebox, OrderSchemaValibot, OrderSchemaZod,
orderValid
} from './fixtures.js';
import { OrderSchema } from '../bookstore/index.js';
export function runRegistryBench(): BenchResult[] {
const results: BenchResult[] = [];
section('registry — cold: register schemas + first validate');
results.push(bench('cold first validate', 'json-tology', () => {
const reg = new SchemaRegistry({ 'enableStrictGraph': false });
for (const schema of bookstoreBenchSchemas) {
reg.set(schema as Record<string, unknown>);
}
return reg.validate(OrderSchema.$id, orderValid);
}, { 'iterations': 5000 }));
results.push(bench('cold first validate', 'typebox', () => {
const compiled = TypeCompiler.Compile(OrderSchemaTypebox);
return compiled.Check(orderValid);
}, { 'iterations': 5000 }));
results.push(bench('cold first validate', 'zod', () => {
// Zod has no compile step; reconstruct the schema each time
const fresh = z.object({
'orderId': z.string().uuid(),
'placedAt': z.string().datetime()
});
return fresh.safeParse({
'orderId': orderValid.orderId as string,
'placedAt': orderValid.placedAt as string
});
}, { 'iterations': 5000 }));
results.push(bench('cold first validate', 'valibot', () => {
return safeParse(OrderSchemaValibot, orderValid);
}, { 'iterations': 5000 }));
section('registry — warm: cached validate (steady state)');
// Warm registries
const reg = new SchemaRegistry({ 'enableStrictGraph': false });
for (const schema of bookstoreBenchSchemas) {
reg.set(schema as Record<string, unknown>);
}
reg.validate(OrderSchema.$id, orderValid);
const tbCompiled = TypeCompiler.Compile(OrderSchemaTypebox);
tbCompiled.Check(orderValid);
OrderSchemaZod.safeParse(orderValid);
safeParse(OrderSchemaValibot, orderValid);
results.push(bench('warm validate', 'json-tology', () => {
return reg.validate(OrderSchema.$id, orderValid);
}));
results.push(bench('warm validate', 'typebox', () => {
return tbCompiled.Check(orderValid);
}));
results.push(bench('warm validate', 'zod', () => {
return OrderSchemaZod.safeParse(orderValid);
}));
results.push(bench('warm validate', 'valibot', () => {
return safeParse(OrderSchemaValibot, orderValid);
}));
return results;
}cold first validate
First validate against a freshly-registered schema. Node-only.
| Library | ops/s | ns/op | json-tology vs this |
|---|---|---|---|
| json-tology | 391 | 2556468 | - |
| typebox | 38,580 | 25920 | 98.67x slower |
| valibot | 781,866 | 1279 | 1999.66x slower |
| zod | 27,088 | 36917 | 69.28x slower |
warm validate
Validate after registration; hot path.
| Library | ops/s | ns/op | json-tology vs this |
|---|---|---|---|
| json-tology | 279,043 | 3584 | - |
| typebox | 3,760,135 | 266 | 13.48x slower |
| valibot | 788,201 | 1269 | 2.82x slower |
| zod | 1,123,934 | 890 | 4.03x slower |
Compiled validator
Measures SchemaCompiler on the same schemas used in the Validation section. There is one validation path — compiled — so the table shows a single json-tology row with no peer comparator. No peer library has an equivalent surface for this isolated measurement.
Source:
/**
* Compiled path benchmark.
*
* Measures SchemaCompiler (closure-based) validation performance against
* the bookstore schemas.
*
* "simple" runs against the bookstore ReviewSchema (flat object).
* "nested" runs against the bookstore OrderSchema (multi-level $refs).
*/
import { SchemaRegistry } from '../../../src/modules/registry/SchemaRegistry.js';
import {
bench, type BenchResult, section
} from './harness.js';
import {
bookstoreBenchSchemas,
orderValid, reviewInvalid, reviewValid
} from './fixtures.js';
import {
OrderSchema, ReviewSchema
} from '../bookstore/index.js';
export function runCompiledBench(): BenchResult[] {
const results: BenchResult[] = [];
// Compiled path via registry — register the full canonical bookstore
// closure so every $ref (Isbn, Money, Address, etc.) resolves.
const registry = new SchemaRegistry({ 'enableStrictGraph': false });
for (const schema of bookstoreBenchSchemas) {
registry.set(schema as Record<string, unknown>);
}
// Force compilation
registry.validate(ReviewSchema.$id, reviewValid);
registry.validate(OrderSchema.$id, orderValid);
section('Compiled — Review (valid)');
const compiledSimpleValid = bench('compiled simple valid', 'compiled', () => {
return registry.validate(ReviewSchema.$id, reviewValid);
});
results.push(compiledSimpleValid);
section('Compiled — Review (invalid)');
const compiledSimpleInvalid = bench('compiled simple invalid', 'compiled', () => {
return registry.validate(ReviewSchema.$id, reviewInvalid);
});
results.push(compiledSimpleInvalid);
section('Compiled — Order (valid)');
const compiledNestedValid = bench('compiled nested valid', 'compiled', () => {
return registry.validate(OrderSchema.$id, orderValid);
});
results.push(compiledNestedValid);
return results;
}compiled simple valid
| Library | ops/s | ns/op | json-tology vs this |
|---|---|---|---|
| json-tology | N/A | N/A | N/A |
| compiled | 813,221 | 1230 | - |
compiled simple invalid
| Library | ops/s | ns/op | json-tology vs this |
|---|---|---|---|
| json-tology | N/A | N/A | N/A |
| compiled | 918,592 | 1089 | - |
compiled nested valid
| Library | ops/s | ns/op | json-tology vs this |
|---|---|---|---|
| json-tology | N/A | N/A | N/A |
| compiled | 285,198 | 3506 | - |
What's unique to json-tology
Operations no comparator implements. Single-library rows in the Node report; included for completeness, not as head-to-head wins.
toTbox: OWL TBox projection from the canonical graph.toShacl: SHACL shape projection.toQuads/fromQuads: RDF round-trip via projection.- ABox projection through
Materializer.projectAbox. findDuplicatesover the registry.- The
jt:keyword set (computed properties, invariants, decoders, brands). - OWL / SHACL emission through
OntologyBuilder.
Known gaps
Scenarios where json-tology trails the comparator field by a meaningful margin. Each is a tracked improvement.
Validation trails compile-to-JS validators (AJV, TypeBox) on flat and nested schemas. The per-validate graph traversal cost is the dominant factor; ref-free flat schemas and cross-schema $ref lookups are both tracked hot-path candidates.
convert (type-cast pass), extend + validate and intersection (cold graph rebuild per derived schema), dumpJson (schema-graph walk per property), discriminated union (warm compiled path), and cold first validate (JIT compilation cost on first use) all show substantial gaps versus the fastest comparators in their category. See the result tables above for current ratios; the npm run bench:report output in examples/docs/benchmarks/results/latest.md lists every scenario that exceeds 5× the median comparator under "Where we have work to do".
See also
- Library comparisons: feature matrix across 11 validators / codecs / ontology tools.
- References: outbound links to every comparator's documentation.