Skip to content

Benchmarks

Every scenario on this page shows three things together:

  1. The source code: the actual .bench.ts file that runs in Node, embedded via vitepress source includes. Click through to GitHub or use the file path to inspect locally.
  2. Browser runner: ▶ Run in browser button. json-tology is loaded directly from this repo's src/ (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 their esm.sh CDN entries on demand. Numbers are directional; browser timing variance is high.
  3. 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.

bash
git clone https://github.com/Studnicky/json-tology.git
cd json-tology
npm install
npm run bench:report

The 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:

bash
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:

ts
/**
 * 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.

11 libs · 20000 iterations
Libraryops/sns/opjson-tology vs this
json-tology2,565,821390-
ajv20,919,587488.15x slower
io-ts4,837,9492071.89x slower
typebox28,193,8333510.99x slower
valibot3,243,8753081.26x slower
zod4,060,4952461.58x slower

simple invalid

Flat 3-property object that fails every constraint, measuring error-collection cost.

11 libs · 20000 iterations
Libraryops/sns/opjson-tology vs this
json-tology2,549,259392-
ajv15,194,876665.96x slower
io-ts6,013,9681662.36x slower
typebox797,87412533.20x faster
valibot1,451,7176891.76x faster
zod123,323810920.67x faster

nested valid

Nested object with $ref sub-schemas; the cross-schema reference case.

11 libs · 20000 iterations
Libraryops/sns/opjson-tology vs this
json-tology537,4131861-
ajv3,344,9572996.22x slower
io-ts1,630,8936133.03x slower
typebox6,338,32415811.79x slower
valibot889,27711251.65x slower
zod1,343,3597442.50x slower

Instantiation

registry.instantiate (no coercion) against TypeBox Value.Parse, Zod parse, Valibot parse, ArkType, io-ts decode.

Source:

ts
/**
 * 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.

11 libs · 20000 iterations
Libraryops/sns/opjson-tology vs this
json-tology519,8731924-
io-ts4,664,5962148.97x slower
typebox788,43612681.52x slower
valibot3,059,1533275.88x slower
zod3,484,7202876.70x slower

instantiate nested

Parse + normalize a nested object.

11 libs · 20000 iterations
Libraryops/sns/opjson-tology vs this
json-tology148,7046725-
io-ts1,594,71662710.72x slower
typebox135,67373711.10x faster
valibot908,74411006.11x slower
zod1,436,5236969.66x slower

Coerce

registry.instantiate with enableTypeCast: true against Zod's z.coerce.*.

Source:

ts
/**
 * 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.

11 libs · 20000 iterations
Libraryops/sns/opjson-tology vs this
json-tology537,9351859-
io-ts4,839,5202079.00x slower
typebox710,48014071.32x slower
valibot3,030,8923305.63x slower
zod3,944,6052547.33x slower

coerce defaults

Apply default values during instantiate.

11 libs · 20000 iterations
Libraryops/sns/opjson-tology vs this
json-tology494,3162023-
io-tsN/AN/AN/A
typebox890,55911231.80x slower
valibotN/AN/AN/A
zodN/AN/AN/A

Value operations

Operations.clone, Value.diff, registry.clean, registry.convert against structuredClone.

Source:

ts
/**
 * 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.

12 libs · 20000 iterations
Libraryops/sns/opjson-tology vs this
json-tology755,3061324-
structuredCloneN/AN/AN/A
typebox938,10510661.24x slower

clean nested

Strip unknown keys from a nested object.

12 libs · 20000 iterations
Libraryops/sns/opjson-tology vs this
json-tology212,2924710-
structuredCloneN/AN/AN/A
typebox325,32830741.53x slower

convert simple

String → number / boolean coercion only.

11 libs · 20000 iterations
Libraryops/sns/opjson-tology vs this
json-tology930,9341074-
structuredCloneN/AN/AN/A
typebox5,828,9631726.26x slower

clone nested

Deep-clone a nested object.

13 libs · 20000 iterations
Libraryops/sns/opjson-tology vs this
json-tology542,0591845-
structuredClone550,78518161.02x slower
typeboxN/AN/AN/A

diff nested

Compute a changeset between two nested objects.

12 libs · 20000 iterations
Libraryops/sns/opjson-tology vs this
json-tology2,003,610499-
structuredCloneN/AN/AN/A
typebox145,663686513.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:

ts
/**
 * 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.

Libraryops/sns/opjson-tology vs this
json-tology151,8796584-
io-ts2,086,40047913.74x slower
typebox1,419,8407049.35x slower
zod1,736,46057611.43x slower

encode date

Encode a Date instance back to a wire-format ISO-8601 string.

Libraryops/sns/opjson-tology vs this
json-tology122,430,1908-
io-ts259,993,50042.12x slower
typebox4,968,02920124.64x faster
zodN/AN/AN/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:

ts
/**
 * 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.

Libraryops/sns/opjson-tology vs this
json-tology12,504,49480-
typebox1,503,8156658.32x faster
valibotN/AN/AN/A
zod79,17012631157.94x faster

extend + validate

Extend + register + run one validation.

Libraryops/sns/opjson-tology vs this
json-tology1,367,193731-
typebox158,373,9436115.84x slower
valibotN/AN/AN/A
zod11,624,302868.50x slower

discriminated union

Validate against a discriminated union of variants.

Libraryops/sns/opjson-tology vs this
json-tology1,859,086538-
typebox143,377,809777.12x slower
valibot6,329,8321583.40x slower
zod18,277,777559.83x slower

intersection

Build an intersection schema, register the parts and the merged form, validate one instance.

Libraryops/sns/opjson-tology vs this
json-tology1,443,698693-
typebox52,746,0931936.54x slower
valibotN/AN/AN/A
zod4,824,9642073.34x slower

Serialization

dump, dumpJson, facade encode against JSON.stringify and structuredClone.

Source:

ts
/**
 * 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.

13 libs · 20000 iterations
Libraryops/sns/opjson-tology vs this
json-tology61,65616219-
JSON.stringifyN/AN/AN/A
structuredClone540,78218498.77x slower
typebox408,21924506.62x slower

dumpJson nested

Serialize a nested object to a JSON string.

12 libs · 20000 iterations
Libraryops/sns/opjson-tology vs this
json-tology84,05511897-
JSON.stringify1,919,79052122.84x slower
structuredCloneN/AN/AN/A
typeboxN/AN/AN/A

encode event

Encode an event-like record through the Transform encoder. Node-only (see Transforms note above).

Libraryops/sns/opjson-tology vs this
json-tology49,048,65220-
JSON.stringifyN/AN/AN/A
structuredCloneN/AN/AN/A
typebox1,978,02950624.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:

ts
/**
 * 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.

Libraryops/sns/opjson-tology vs this
json-tology3912556468-
typebox38,5802592098.67x slower
valibot781,86612791999.66x slower
zod27,0883691769.28x slower

warm validate

Validate after registration; hot path.

11 libs · 20000 iterations
Libraryops/sns/opjson-tology vs this
json-tology279,0433584-
typebox3,760,13526613.48x slower
valibot788,20112692.82x slower
zod1,123,9348904.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:

ts
/**
 * 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

Libraryops/sns/opjson-tology vs this
json-tologyN/AN/AN/A
compiled813,2211230-

compiled simple invalid

Libraryops/sns/opjson-tology vs this
json-tologyN/AN/AN/A
compiled918,5921089-

compiled nested valid

Libraryops/sns/opjson-tology vs this
json-tologyN/AN/AN/A
compiled285,1983506-

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.
  • findDuplicates over 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.

Released under the MIT License.