Fast, zero-dependency metadata schema and interface definition language for JavaScript. Declare data structures and domain models once as plain objects; validate and parse data against them at tens of millions of checks a second, with every problem returned as data; and generate TypeScript types and JSON Schema from the same definitions. Node.js and browsers.
const { Schema } = require('@alexify/metaschema');
const user = new Schema('User', {
name: { first: 'string', last: 'string' },
email: { type: 'string', length: { min: 5, max: 64 } },
age: '?number',
roles: { array: { enum: ['admin', 'editor', 'viewer'] } },
});
const result = user.check({ name: { first: 'Marcus' }, email: 'm@r', roles: ['owner'] });
result.errors;
// [
// 'Field "User.name.last" is required',
// 'Field "User.email" value is too short',
// 'Field "User.roles[0]" value is not of enum: admin, editor, viewer'
// ]
result.issues;
// [
// { code: 'required', path: ['name', 'last'], message: 'is required', params: {} },
// { code: 'length', path: ['email'], message: 'value is too short', params: { min: 5, max: 64, actual: 3 } },
// { code: 'enum', path: ['roles', 0], message: 'value is not of enum: admin, editor, viewer', params: { values: ['admin', 'editor', 'viewer'] } }
// ]
const event = Schema.from({
name: 'string',
'level?': { type: 'integer', default: 1 },
when: { type: 'string', parse: (text) => new Date(text) },
});
event.parse({ name: 'deploy', when: '2026-10-10T12:00:00.000Z' }).value;
// { name: 'deploy', when: 2026-10-10T12:00:00.000Z, level: 1 }- Compact syntax.
'?string'is an optional string,'tags?'an optional key,{ array: 'number' }an array,['number', 'number']a tuple,'Company'a reference. The long form ({ type: 'string', length: [3, 32] }) is there when a field needs options. - Errors, not exceptions.
checkwalks the whole value and returns every problem as data: a code, the path as an array of keys, the params it was made from, and a message rendered through a locale (English built in,@alexify/metaschema/locales/ukshipped). Only a broken definition throws. - Parse, not only a verdict.
parseruns the same check and gives the value of the program: defaults filled in, unknown keys stripped on request, a field'sparsefunction decoding its value (a date-time string into aDate) andserializethe way back;checkAsyncandparseAsyncawait validators and decoders that return promises. - String formats.
{ type: 'string', format: 'email' }is a rule besidelengthandpattern;@alexify/metaschema/formatsregisters 35 formats (uuid,ulid,jwt,email,date-time,duration,ipv4,cidrv6,url,json-pointer,e164,iban,base64,semver, …) and your own, and the name travels into JSON Schema, samples and inference. - Coercion.
parsewithcoerce: true(a call, a schema or a field) converts the text of a query string, a form or a CSV row to the types of the fields before the check ('42'to a number, an ISO string to aDate), strictly and never incheck;coerce()of@alexify/metaschema/coerceis that step alone. - Deriving schemas.
pick,omit,partial(deeptoo),required,extendandkeyofof@alexify/metaschema/derivemake the create and update bodies and the views of an entity from the entity, typed by its definition. - MongoDB.
@alexify/metaschema/mongodbis the schema side of Mongoose without the driver dependency: BSON types,_idand ObjectId references written and read back, the indexes and the collection validator of a model, and the driver's updates checked against the schema. - Records and streams.
checkEachandparseEachof@alexify/metaschema/streamrun a schema over an iterable of records, lazily and in order, yielding{ index, record, result }for each; the async pair keepsconcurrencyrecords in flight over a sync or async source, andtransformis aTransformStreamthat lets the valid records through and reports the others. - Domain models. Entities, registries, dictionaries and projections, with references, relations and indexes, ordered by dependency and checked for missing references.
- TypeScript.
InferSchema<typeof schema>is the type of a value the schema accepts, computed from the definition with nothing generated, and a model renders its entities as interfaces. The package ships hand-written typings for its own API. - Standard Schema. Every schema implements Standard Schema v1
(
schema['~standard'], which isparse), so tRPC, TanStack Form, Hono and any other consumer of the interface take it as they take a zod or valibot schema. - JSON Schema.
toJSONSchemarenders a schema or a whole model as JSON Schema draft 2020-12 or draft-07, an OpenAPI 3.0 schema object or a MongoDB$jsonSchema, with a strict profile for LLM structured outputs;~standard.jsonSchemais the Standard JSON Schema converter. The generators live in the@alexify/metaschema/generatorssubpath, so a program that only validates does not carry them. - Fast. Every check is compiled into a closure on its first use and runs in a context
of its own: about 10 million validations a second of a flat struct on Node 24, and about 30
million through the JIT backend of
@alexify/metaschema/compile, which generates the same check wherenew Functionis allowed and keeps the closures where it is not. Against ten other validators see Benchmarks; how the core gets there is on the Performance page. - The model as data.
model.toJSON()is a snapshot other programs read the model through (a migration tool, an introspecting service, a committed baseline),schema.toSnapshot()the same document for one schema and the entities it reaches (a codec built from it), with the guarantees of format 1 written down, and@alexify/metaschema/modeltells two versions apart (diff), classifies the change by the Confluent rules (compatibility), draws a value every schema accepts (sample), unifies two schemas (merge) and infers a definition from the values that exist (infer);aliasesanddeprecatedlet a model carry its history, andfromJSONSchemaof the generators reads a JSON Schema document back. - Zero dependencies, 21.3 KB min+gzip. CommonJS with ESM named imports, no build step,
one package for Node.js and browsers; the generators add 7.7 KB, the JIT backend 4.3 KB, the
model tools 12.7 KB, the record tools 0.9 KB, the string formats 3.9 KB, the derive tools
2.2 KB, the converters of coercion 1.9 KB and the MongoDB tools 3.6 KB (on top of the
converters, which they require) where they are imported. A program that reads schemas
without validating them (a client that encodes from their snapshots) imports
@alexify/metaschema/definition, the half of the core that builds them: 10.3 KB.
metaschema on its two backends beside Metarhia's metaschema, Zod, Valibot, ArkType, Effect
Schema, TypeBox, Ajv, Yup, Joi and Superstruct: the object of the moltar suite in its four modes,
every issue of an invalid value and building a schema, each library in a fresh process and
checked for the right verdicts before it is timed. On the JIT metaschema validates 30 to 48
million objects a second, and in the strict modes (unknown keys rejected) only TypeBox's compiler
is faster; on its closures, with no eval at all, 4.6 to 9.8 million, ahead of every
interpreter in the set. Asked for every issue of an invalid value with its path, it is second
only to Ajv, and 6 to 100 times faster than the other TypeScript-first validators; a cold start
(load, build, first check) takes 5.7 ms.
| Library | Flat object, parseSafe ops/s |
Flat object, parseStrict ops/s |
Flat object, assertLoose ops/s |
Flat object, assertStrict ops/s |
Nested object, parseSafe ops/s |
Nested object, parseStrict ops/s |
Nested object, assertLoose ops/s |
Nested object, assertStrict ops/s |
Every issue of an invalid value ops/s |
Build a schema schemas/s |
|---|---|---|---|---|---|---|---|---|---|---|
| metaschema check() · compile(), JIT · v3.3.0 |
48,400,000 | 38,900,000 | 49,700,000 | 40,800,000 | 42,000,000 | 29,900,000 | 44,100,000 | 30,300,000 | 6,650,000 | 32,300 |
| metaschema check() · closures · v3.3.0 |
9,430,000 | 9,020,000 | 10,000,000 | 9,200,000 | 5,100,000 | 4,810,000 | 5,090,000 | 4,750,000 | 3,000,000 | 190,000 |
| Metarhia metaschema check() · strict only · v2.2.2 |
— | 958,000 | — | 959,000 | — | 550,000 | — | 546,000 | 428,000 | 282,000 |
| Zod safeParse() · v4.6.6 |
26,500,000 | 12,800,000 | 27,800,000 | 12,700,000 | 16,700,000 | 7,340,000 | 16,900,000 | 7,160,000 | 445,000 | 82,100 |
| Valibot safeParse() · is() · v1.5.0 |
3,570,000 | 2,950,000 | 3,620,000 | 3,010,000 | 2,130,000 | 1,800,000 | 2,080,000 | 1,780,000 | 1,150,000 | 2,050,000 |
| ArkType type(value) · allows() · v2.2.8 |
50,500,000 | 7,400,000 | 171,000,000 | 8,640,000 | 38,100,000 | 4,020,000 | 134,000,000 | 4,430,000 | 68,000 | 14,600 |
| Effect Schema decodeUnknownResult() · is() · v4.0.3 |
4,200,000 | 1,970,000 | 4,220,000 | 1,980,000 | 2,310,000 | 1,000,000 | 2,310,000 | 1,000,000 | 359,000 | 672,000 |
| TypeBox Compile(…).Check() · v1.3.36 |
165,000,000 | 79,900,000 | 188,000,000 | 83,700,000 | 119,000,000 | 43,200,000 | 136,000,000 | 43,800,000 | 90,600 | 36,200 |
| TypeBox Value.Check() · v1.3.36 |
263,000 | 191,000 | 264,000 | 192,000 | 170,000 | 118,000 | 167,000 | 114,000 | 89,400 | 422,000 |
| Ajv compile(…)(value), draft 2020-12 · v8.20.0 |
65,700,000 | 34,600,000 | 71,900,000 | 35,300,000 | 51,500,000 | 23,500,000 | 55,400,000 | 24,100,000 | 15,000,000 | 1,840 |
| Yup validateSync() · isValidSync(), strict · v1.7.1 |
313,000 | 298,000 | 314,000 | 295,000 | 184,000 | 175,000 | 185,000 | 168,000 | 11,800 | 53,900 |
| Joi validate(), convert: false · v18.2.9 |
851,000 | 843,000 | 834,000 | 855,000 | 534,000 | 543,000 | 535,000 | 525,000 | 257,000 | 37,400 |
| Superstruct validate() · is() · v2.0.2 |
492,000 | 453,000 | 496,000 | 443,000 | 304,000 | 266,000 | 299,000 | 257,000 | 100,000 | 1,770,000 |
Apple M3 Max · 16 cores · Node v24.14.1 · median of 5 × 1 s samples, each library in a fresh process · 2026-10-13. Medians, rounded to three significant figures; — where the library cannot express the mode.
How to read these numbers and the method behind them are on the Benchmarks page, where the charts are interactive.
| Library | Load | Build | First check | Total |
|---|---|---|---|---|
| Superstruct validate() · is() · v2.0.2 |
0.7 ms | 0.2 ms | 0.3 ms | 1.2 ms |
| Valibot safeParse() · is() · v1.5.0 |
3.7 ms | 0.1 ms | 0.1 ms | 3.9 ms |
| Yup validateSync() · isValidSync(), strict · v1.7.1 |
2.4 ms | 1.1 ms | 0.7 ms | 4.2 ms |
| metaschema check() · closures · v3.3.0 |
4.7 ms | 1.0 ms | 0.4 ms | 6.1 ms |
| metaschema check() · compile(), JIT · v3.3.0 |
5.9 ms | 1.7 ms | 0.2 ms | 7.8 ms |
| Metarhia metaschema check() · strict only · v2.2.2 |
20.7 ms | 0.6 ms | 0.2 ms | 21.5 ms |
| Joi validate(), convert: false · v18.2.9 |
23.1 ms | 1.2 ms | 0.6 ms | 25.0 ms |
| Zod safeParse() · v4.6.6 |
27.7 ms | 1.5 ms | 1.2 ms | 30.3 ms |
| Ajv compile(…)(value), draft 2020-12 · v8.20.0 |
14.6 ms | 24.4 ms | 0.2 ms | 39.3 ms |
| Effect Schema decodeUnknownResult() · is() · v4.0.3 |
72.4 ms | 0.2 ms | 0.8 ms | 73.4 ms |
| TypeBox Value.Check() · v1.3.36 |
96.6 ms | 0.1 ms | 1.1 ms | 97.8 ms |
| TypeBox Compile(…).Check() · v1.3.36 |
96.9 ms | 1.9 ms | 0.0 ms | 98.9 ms |
| ArkType type(value) · allows() · v2.2.8 |
107.8 ms | 1.4 ms | 0.1 ms | 109.3 ms |
| Library | min | min+gzip | |
|---|---|---|---|
| Valibot safeParse() · is() · v1.5.0 |
3.3 KB | 1.2 KB | |
| Superstruct validate() · is() · v2.0.2 |
3.5 KB | 1.5 KB | |
| Metarhia metaschema check() · strict only · v2.2.2 |
30.0 KB | 11.0 KB | minified with keepNames: its checks compare constructor names |
| Yup validateSync() · isValidSync(), strict · v1.7.1 |
41.4 KB | 13.2 KB | |
| metaschema check() · closures · v3.3.0 |
52.6 KB | 18.9 KB | |
| metaschema check() · compile(), JIT · v3.3.0 |
65.2 KB | 23.1 KB | |
| Effect Schema decodeUnknownResult() · is() · v4.0.3 |
72.9 KB | 24.4 KB | |
| Zod safeParse() · v4.6.6 |
88.4 KB | 25.7 KB | |
| TypeBox Value.Check() · v1.3.36 |
146.7 KB | 37.5 KB | |
| Ajv compile(…)(value), draft 2020-12 · v8.20.0 |
130.9 KB | 39.5 KB | |
| TypeBox Compile(…).Check() · v1.3.36 |
161.5 KB | 40.8 KB | |
| ArkType type(value) · allows() · v2.2.8 |
151.9 KB | 46.4 KB | |
| Joi validate(), convert: false · v18.2.9 |
169.5 KB | 53.3 KB |
| Scenario | Metarhia 2.2.2 | 1.0.0 | 2.0.0 | 3.0.0 | 3.0.0 JIT |
3.1.0 | 3.1.0 JIT |
3.2.0 | 3.2.0 JIT |
3.3.0 | 3.3.0 JIT |
|---|---|---|---|---|---|---|---|---|---|---|---|
| Schema.from — flat struct | 581,000 | 321,000 | 362,000 | 372,000 | — | 367,000 | — | 356,000 | — | 332,000 | — |
| Schema.from — nested struct | 156,000 | 95,600 | 104,000 | 99,000 | — | 98,400 | — | 98,600 | — | 91,100 | — |
| check — flat struct, valid | 1,440,000 | 2,180,000 | 10,800,000 | 10,700,000 | 38,600,000 | 11,700,000 | 37,900,000 | 11,600,000 | 37,900,000 | 11,600,000 | 38,200,000 |
| check — flat struct, invalid | 1,010,000 | 1,110,000 | 4,020,000 | 3,940,000 | 6,130,000 | 3,880,000 | 6,170,000 | 3,900,000 | 6,160,000 | 3,850,000 | 6,240,000 |
| check — nested struct, valid | 238,000 | 368,000 | 2,950,000 | 2,930,000 | 19,700,000 | 2,920,000 | 19,600,000 | 2,910,000 | 19,700,000 | 2,910,000 | 19,700,000 |
| check — nested struct, invalid | 222,000 | 300,000 | 1,570,000 | 1,580,000 | 2,140,000 | 1,570,000 | 2,110,000 | 1,570,000 | 2,110,000 | 1,590,000 | 2,120,000 |
Apple M3 Max · 16 cores · Node v24.14.1 · median of 5 × 1 s samples, each release in a fresh process · 2026-10-13. ops/s (schemas/s for Schema.from), medians.
metaschema against twenty alternatives in four directions: TypeScript-first validators, JSON Schema validators and compilers, interface definition languages built for schema evolution, and an ODM. Every cell was checked against the source of the published package (October 2026), not its README; the comparison page has the method, what the table shows and where each library fits, in a table you can scroll with the features pinned.
| Feature | metaschema schemas + IDL |
Metarhia metaschema IDL, the original |
Zod TS-first |
Valibot TS-first |
ArkType TS-first |
Effect Schema TS-first |
Superstruct TS-first |
io-ts TS-first |
Runtypes TS-first |
myzod TS-first |
Valita TS-first |
Yup validator |
Joi validator |
VineJS validator |
class-validator decorators |
Ajv JSON Schema |
TypeBox JSON Schema |
typia AOT compiler |
avsc IDL · Avro |
protobuf.js IDL · Protobuf |
Mongoose ODM |
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| Package | |||||||||||||||||||||
| Version compared | 3.4.0 | 2.2.2 | 4.6.6 | 1.5.0 | 2.2.8 | 4.0.3 | 2.0.21 | 2.2.22 | 7.0.5 | 1.12.12 | 0.5.5 | 1.7.1 | 18.2.9 | 4.4.0 | 0.15.1 | 8.20.0 | 1.3.363 | 15.1.0 | 5.7.9 | 8.8.1 | 9.11.1 |
| Latest release | 2026-10 (this release) | 2025-05 | 2026-10 | 2026-09 | 2026-10 | 2026-10 | 2024-07 | 2024-12 | 2026-08 | 2025-02 | 2026-10 | 2025-09 | 2026-09 | 2026-04 | 2026-02 | 2026-04 | 2026-10 | 2026-10 | 2025-07 | 2026-10 | 2026-10 |
| Runtime dependencies | ✅ 0 | 2: metautil, metavm | ✅ 0 | ✅ 0 | 3: @ark/schema, @ark/util, arkregex | ✅ 0 | ✅ 0 | 0 + peer fp-ts | ✅ 0 | ✅ 0 | ✅ 0 | 4: property-expr, tiny-case, toposort, type-fest | 7 (@hapi/*, …) | 10 (validator.js, dayjs, …) | 3 (validator.js, libphonenumber-js, …) | 4 | ✅ 0 | 3: randexp, @typia/interface, @typia/utils | ✅ 0 | 1: long | 7, incl. mongodb driver |
| License | MIT | MIT | MIT | MIT | MIT | MIT | MIT | MIT | MIT | MIT | MIT | MIT | BSD-3-Clause | MIT | MIT | MIT | MIT | MIT | MIT | BSD-3-Clause | MIT |
| Runtime | |||||||||||||||||||||
| Runs on | ✅ Node 18+, browsers, edge | ✅ Node, browsers, Deno, Bun, edge | ✅ Node, browsers, Deno, Bun, edge | ✅ Node, browsers, Deno, Bun, edge | ✅ Node, browsers, Deno, Bun, edge | ✅ Node ≥14, browsers, edge | ✅ Node, browsers, edge | ✅ Node, Deno (JSR), browsers, edge | ✅ Node ≥22, Deno (JSR), browsers, edge | ✅ Node, browsers, Deno, Bun, edge | ✅ Node, browsers, Deno, Bun, edge | ✅ Node, browsers, Deno, Bun, edge | ✅ generated JS runs anywhere | ||||||||
| Module format | CJS, ESM named imports | CJS | ESM + CJS | ESM + CJS | ESM | ESM | ESM + CJS | CJS + ESM (module field) | ESM + CJS | CJS | ESM | ESM + CJS | CJS | ESM | ESM + CJS | CJS | ESM | ESM + CJS | CJS | CJS (+ UMD dist) | CJS |
| Works without eval (strict CSP, edge) | ✅ closures; JIT opt-in, falls back | ✅ never (validation) | ✅ never | ✅ never by default (JIT opt-in) | ✅ never | ✅ never | ✅ never | ✅ never | ✅ never | ✅ never | ✅ never | ❌ new AsyncFunction per schema | ✅ never | ✅ code is generated at build time | ❌ needs new Function | ✅ never (validation) | |||||
| Works without a build step | ✅ plain JS | ✅ plain JS | ✅ plain JS | ✅ plain JS | ✅ plain JS | ✅ plain JS | ✅ plain JS | ✅ plain JS | ✅ plain JS | ✅ plain JS | ✅ plain JS | ✅ plain JS | ✅ plain JS | ✅ plain JS | ✅ plain JS; standalone optional | ✅ plain JS | ❌ requires ttsc transform (TypeScript 7)12 | ✅ plain JS | ✅ plain JS | ||
| Defining schemas | |||||||||||||||||||||
| How a schema is written | plain object: '?string', { array: 'number' } | plain object literal ('?string', kind key) | builder / method chain (zod/mini: functional) | functions + pipe() | TS-syntax strings in object literals + chaining | builder values: Schema.Struct({...}).pipe/.check | function combinators: object({ a: string() }) | codec combinators: t.type({ a: t.string }) | runtype values: Object({ a: String }) | builder chain: myzod.object({ a: myzod.string() }) | builder chain: v.object({ a: v.string() }) | builder / method chain | builder / method chain | builder / method chain (mutable) | decorators on classes | JSON Schema or JTD document | builder (Type.Object…); TS-syntax strings via Type.Script | TS types + transformer; type tags | Avro schema JSON (.avsc) or IDL | .proto IDL or JSON descriptor | Mongoose Schema (SchemaType options) |
| The definition is data (store, send, rebuild) | ✅ plain objects (functions aside) | ✅ plain objects (functions aside) | ❌ export only (@valibot/to-json-schema) | ✅ string/object defs; .json IR | ✅ SchemaRepresentation.toJson / fromJson | ❌ no | ❌ no | ❌ no | ❌ no | ❌ no | ❌ describe() is one-way | ✅ describe() ↔ Joi.build() | ❌ toJSON() holds functions | ❌ registerSchema() is broken13 | ✅ JSON Schema / JTD documents | ✅ types are JSON Schema objects | ✅ Avro JSON schema | ✅ JSON descriptors | |||
| Optional vs nullable | ✅ '?x', 'key?', nullable: true | ✅ optional, exactOptional, nullable, nullish | ✅ optional, exactOptional, nullable, nullish, undefinedable | ✅ 'key?' (no undefined), '| null', '| undefined' | ✅ optionalKey, optional, NullOr, UndefinedOr, NullishOr | ✅ optional(), nullable() | ✅ t.partial vs t.union([X, t.null]) | ✅ optional(), nullable(), undefinedable() | ✅ optional(), nullable() | ✅ optional(), nullable() | ✅ optional() / nullable() / defined() | ✅ optional default; allow(null); required() | ✅ required vs type null / nullable | ✅ Optional, Union with Null, Undefined | ✅ TS ?:, | null, | undefined | ||||||
| Unions, discriminated | ✅ union, discriminator | ❌ none | ✅ union, discriminatedUnion, xor | ✅ union, variant (discriminated) | ✅ 'a | b', .or(); auto-discriminated | ✅ Union, Literals, TaggedUnion | ✅ union(), tag detected automatically | ✅ union(), auto-discriminated | ❌ no union type | ✅ alternatives(); conditional() switch | ✅ union() with if(), unionOfTypes() | ✅ anyOf / oneOf; discriminator opt-in | ✅ Type.Union (anyOf); no discriminator | ✅ TS unions; discriminant detected | ✅ unions; wrapped = tagged by name | ✅ oneof (virtual discriminator) | ✅ Schema.Types.Union; discriminators | ||||
| Tuples | ✅ ['number', 'string'] | ✅ z.tuple with rest | ✅ tuple, strictTuple, tupleWithRest | ✅ ['string', 'number'], variadic '...' | ✅ Tuple, TupleWithRest | ✅ tuple([..]) | ✅ Tuple(...), with Spread rest | ✅ tuple([..]) | ✅ tuple([..]), variadic via concat16 | ✅ tuple() | ✅ array().ordered() | ✅ vine.tuple() | ❌ none | ✅ prefixItems + items: false | ✅ Type.Tuple | ✅ TS tuples | ❌ none | ❌ none | ❌ none | ||
| Recursive schemas | ✅ by name, a schema its own without a model; cycles tracked | ✅ by entity name in a Model | ✅ getters or z.lazy | ✅ v.lazy | ✅ scope names, 'this' | ✅ Schema.suspend | ✅ lazy(() => S) | ✅ t.recursion(name, f) | ✅ Lazy(() => R) | ✅ lazy(() => S) | ✅ lazy(() => T) | ✅ lazy() | ✅ link('#id') | ❌ none | ✅ @ValidateNested + @Type(() => Self) | ✅ $ref, $dynamicRef | ✅ Type.Cyclic, Type.Module | ✅ recursive TS types | ✅ named types | ✅ self-referencing messages | ✅ schema.add({ self: schema }) |
| Schemas referenced by name | ✅ capitalized names in a Model | ✅ capitalized names via Model | ❌ JS variables only | ❌ JS variables only | ✅ scope({ User, Group }) by name | ❌ JS variables only | ❌ none | ❌ none | ❌ none | ❌ none | ❌ none | ❌ none (ref() is a value ref) | ✅ id() + link('#id'), shared() | ❌ none | ❌ none | ✅ $ref by $id; ajv.addSchema | ✅ Type.Ref + context, Module, $id | ✅ TS type names | ✅ names + namespaces, registry | ✅ full names via Root | |
| Custom types registered by name | ✅ registered by name | ✅ Model types: js alias or checkType | ❌ z.custom() values, no name registry | ❌ v.custom() values, no name registry | ✅ scope aliases, generics, keywords | ✅ Joi.extend({ type }) | ✅ registerDecorator(), @ValidatorConstraint | ✅ custom tags (tags.TagBase) | ✅ logicalTypes classes, typeHook | ❌ messages and enums only | ✅ Schema.Types.Name = class | ||||||||||
| String formats | ✅ 35 + your own (./formats) | ❌ none | ✅ 30+ (email, uuid, url, ip, iso.datetime…) | ✅ ~37 actions (email, uuid, url, isoDateTime…) | ✅ ~18 string.* (email, uuid, url, ip…) | ✅ uuid, ulid, base64, URL, IP; no email | ❌ none (pattern() only) | ❌ none (Template literals) | ❌ none (pattern() only) | ❌ none | ✅ 4: email, url, uuid, datetime17 | ✅ 14: email, uri, uuid, ip, isoDate... | ✅ 18: email, url, uuid, ulid, ip, iban, jwt... | ✅ 50+ via validator.js (IsEmail, IsUUID, IsUrl...) | ✅ ajv-formats: 2618 | ✅ 21 built in19 | ✅ 22 via tags.Format | ❌ none | ❌ none | ❌ none (match: RegExp) | |
| Date, BigInt, Map, Set | ✅ Date, BigInt, Map, Set, Uint8Array; BSON (./mongodb) | ✅ Date, BigInt, Map, Set, Symbol, File | ✅ Date, BigInt, Map, Set, Blob, File | ✅ Date, bigint, Map, Set, symbol, Blob, File… | ✅ Date, BigInt, ReadonlyMap, ReadonlySet, Symbol… | ✅ Date, BigInt, Map, Set | ✅ Date, bigint, Map, Set, typed arrays21 | ✅ Date, BigInt, Map, Buffer | |||||||||||||
| Custom validators | ✅ validate on a field and a schema | ✅ field and schema validate24 | ✅ .refine / .superRefine / .check | ✅ check (field), forward+partialCheck (object) | ✅ .narrow() on fields and objects | ✅ .check(makeFilter), refine | ✅ refine(struct, name, fn) | ✅ refinement(), brand() | ✅ withConstraint, withGuard, withAssertion | ✅ withPredicate(fn, msg) | ✅ assert(fn, msg), chain() | ✅ test() on fields and objects | ✅ custom() on any schema | ✅ createRule() + use(), any level | ✅ custom keywords (addKeyword) | ✅ Type.Refine on any type | ❌ none | ✅ validate; pre('validate') + invalidate | |||
| Async validators | ✅ checkAsync, run concurrently | ❌ none | ✅ parseAsync; concurrent | ✅ *Async schemas; concurrent25 | ❌ not supported (by design)26 | ✅ Effect getters; concurrency option | ❌ none | ❌ none | ❌ none28 | ❌ none (non-goal)29 | ✅ async test(), concurrent | ✅ async constraints, concurrent31 | ❌ sync only32 | ❌ none | ❌ none | ❌ none | ✅ async validators, concurrent | ||||
| Validation results | |||||||||||||||||||||
| Every issue in one run | ✅ all, by default | ✅ all by default | ✅ all by default | ✅ all by default; abortEarly | ✅ all by default | failures() | ✅ all by default | ✅ all by default | ✅ all by default | ✅ all fields; first per field | ✅ all by default; stopAtFirstError | allErrors: true33 | Errors(): up to 8 by default | ✅ validate: all; assert: first | errorHook | ❌ first error only | ✅ all paths, one per path | ||||
| A cap on the number of issues | ✅ maxErrors | ❌ none | ❌ first (validate) or all | ❌ first or all | ❌ all only | ❌ first or all | ❌ first or all only | ❌ all only | ❌ all only | ❌ first or all only | ❌ all only | ❌ first or all | ❌ first or all | ❌ none | ❌ none | ❌ first or all | maxErrors (8), process-wide | ❌ first (assert) or all (validate) | ❌ first or all only | ❌ first only | ❌ none |
| Issues as data: code, path, params | ✅ code, path array, params | ❌ message strings34 | ✅ code, path[], expected/minimum… | ✅ type, path[], expected/received/requirement | ✅ code, path[], expected/actual/rule | ✅ tagged issue tree; paths; check params | ✅ code, path array, expected/keys/bounds | ✅ type, path array, context | ❌ string with dotted path | ||||||||||||
| Localized messages | ✅ en, uk; a locale per call | ❌ none | ✅ 62 locales; per-call error fn | ✅ @valibot/i18n (34 locales); per-call message fn35 | ❌ none (per-call message string) | ❌ none | ❌ none | ❌ none (per-schema messages) | ❌ none | ✅ messages + errors.language per call | ✅ messagesProvider per call; English only | ✅ ajv-i18n: 23 locales | ✅ 38 locales; global Locale.Set | ❌ no messages | ❌ none | ❌ none | |||||
| Unknown keys | ✅ reject (default), ignore, strip | ✅ strip (default), strict, loose | ✅ strip (default), strict, loose, rest | ✅ ignore (default), reject, delete | ✅ strip (default), error; keep via StructWithRest | ✅ object() rejects; type() ignores; mask() strips | ✅ check ignores, parse strips, exact() rejects37 | ✅ reject (default), strip | ✅ strict (default), strip, passthrough per call | ✅ keep default; exact() rejects, stripUnknown38 | ✅ reject default; allowUnknown, stripUnknown | ✅ keep default; whitelist strips, forbidNonWhitelisted40 | ✅ ignore default; reject, strip (removeAdditional) | ✅ ignore default; reject, Value.Clean strips | ✅ ignore default; equals rejects, prune strips41 | ✅ ignore (default), reject, strip | ✅ ignore, strip, reject42 | ✅ strip (default), keep, throw | |||
| A result instead of an exception | ✅ check, parse return a result | ✅ safeParse | ✅ safeParse, is | ✅ T(data) returns data | ArkErrors | ✅ decodeUnknownResult / Exit / Option | ✅ validate() → [error, value]; is() | ✅ decode() → Either | ✅ inspect() → result; guard() | ✅ try() → value | ValidationError | ✅ try() → { ok, value | issues } | ✅ validate() → { value, error } | ✅ tryValidate() → [error, value] | ✅ validate() → ValidationError[] | ✅ validate() → boolean + .errors | ✅ Check + Errors; Parse throws | ✅ is / validate | ✅ isValid() -> boolean | ✅ verify() -> null | string | ✅ validateSync() returns the error44 | ||
| Cyclic and very deep input | ✅ tracked at references, maxDepth | ❌ no detection; stack overflow | ❌ stack overflow, no depth limit | ❌ stack overflow, no depth limit46 | ❌ stack overflow | ❌ stack overflow | ❌ stack overflow | ❌ stack overflow | ✅ link.depth issue; maxRecursion(n) | ❌ stack overflow | ❌ RangeError; no detection or depth limit | ❌ RangeError; no detection or depth limit | ❌ stack overflow | ✅ depth limit 100 | ❌ throws (circular JSON, RangeError) | ||||||
| Parsing | |||||||||||||||||||||
| Defaults | ✅ default, a value or a factory | ❌ kept as metadata only | ✅ .default(), .prefault(), .catch() | ✅ optional(schema, default), fallback | ✅ 'string = "x"', .default() | ✅ withDecodingDefault(Key), withConstructorDefault | ✅ defaulted(), via create()/mask()49 | ✅ default(value), parse only50 | ✅ default(value | fn) | ✅ optional(() => v), nullable(() => v) | ✅ default(), factories | ✅ default(), factories | ❌ none (parse() can fill) | ✅ field initializers (class-transformer) | ✅ useDefaults: true (in place)51 | ❌ Default<V> is metadata only | ✅ field default (clone, decode) | ✅ proto defaults, toObject({ defaults }) | ✅ default values and functions | ||
| Transforms and decoders | ✅ a field's parse (decoder) | ❌ none | ✅ .transform, z.pipe, z.preprocess | ✅ transform, rawTransform in pipe | ✅ morphs: .pipe(), .to(), '=>' | ✅ decodeTo + SchemaTransformation | ✅ coerce(struct, from, fn) via create() | ✅ custom Type with validate/encode | ✅ withParser(fn), parse only | ✅ map(fn) | ✅ map(fn), chain(fn) | ✅ transform() | ✅ custom() return value, rename(), trim()... | ✅ transform(), parse() | ✅ @Transform (class-transformer) | ✅ Type.Codec / Decode / Encode | ✅ logical types (_fromValue) | ✅ set/get, trim, lowercase | |||
| Coercion of input types | ✅ coerce: true (call, kind or field) + ./coerce53 | ❌ none | ✅ z.coerce.*, z.stringbool | ✅ toNumber, toDate, toBoolean… actions | ✅ string.numeric.parse, string.date.parse… | ✅ NumberFromString, DateFromString… | ❌ none (trimmed() only) | ❌ none | ✅ number coerce, date, bigint, enum case54 | ❌ none | ✅ on by default; strict() off | ✅ convert: true by default55 | ✅ form semantics by default; { strict: true } | ✅ @Type / implicit (class-transformer) | ✅ coerceTypes: true | "array" | ✅ Value.Convert (also inside Decode) | ✅ fromObject casts56 | ✅ casting on by default | |||
| Encode back (bidirectional codecs) | ✅ serialize | ❌ none | ✅ z.codec + z.encode | 🔜 planned for v2 (#658) | ❌ none | ✅ encodeSync / encodeUnknown* | ❌ none | ✅ codec.encode() | ❌ none | ❌ none | ❌ none | ❌ none | ❌ none | ❌ none | ✅ instanceToPlain (class-transformer) | ✅ Codec + Value.Encode | ✅ toBuffer/fromBuffer; logical _toValue | ✅ binary, toObject, ProtoJSON | |||
| TypeScript | |||||||||||||||||||||
| Static type inferred from the schema | ✅ Infer, InferSchema | ❌ none | ✅ z.infer | ✅ InferOutput | ✅ typeof T.infer | ✅ typeof S.Type | ✅ Infer<typeof S> | ✅ t.TypeOf<typeof C> | ✅ Static<typeof R> | ✅ Infer<typeof S> | ✅ v.Infer<typeof T> | ✅ InferType | ❌ none | ✅ Infer<typeof schema> | ✅ Static<T>, also from plain JSON Schema | ✅ the TS type is the schema | ❌ none | ✅ InferSchemaType | |||
| Separate input and output types | ✅ Infer / InferOutput | ❌ none | ✅ z.input / z.output | ✅ InferInput / InferOutput | ✅ T.inferIn / T.inferOut | ✅ S.Type / S.Encoded | ❌ none | ✅ TypeOf, InputOf, OutputOf | ✅ Static vs Parsed | ❌ none | ❌ output only | ❌ none | ✅ InferInput / Infer | ❌ none | — no transforms | ✅ StaticDecode / StaticEncode | — no transforms or defaults | ❌ none | |||
| Generates TypeScript source | ✅ toTypeScript, saveTypes | ❌ none | ❌ .expression is ArkType syntax, not TS | ✅ SchemaRepresentation.toCodeDocument | ❌ none | ❌ none | ❌ none | ❌ none | ❌ none | ❌ none | ❌ none | — schemas are TS classes | — the source is TypeScript | ✅ pbjs --dts / pbts (protobufjs-cli) | |||||||
| Interoperability | |||||||||||||||||||||
| Standard Schema | ✅ v1 + JSON Schema converter | ❌ no | ✅ v1 + ~standard.jsonSchema | ✅ v1 on every schema | ✅ v1 + ~standard.jsonSchema | ✅ v1 via toStandardSchemaV1()58 | ❌ no | ❌ no | ❌ no | ❌ no | ✅ v1 (~standard), libraryOptions.mode | ✅ v1, always async59 | ✅ v1, + Standard JSON Schema | ❌ none | ❌ none | ❌ none | ✅ createValidate<T>() (v1) | ❌ no | ❌ no | ✅ Model['~standard'] v1 (9.7+)61 | |
| JSON Schema export | ✅ 2020-12, draft-07, OpenAPI 3.0, MongoDB | ❌ none | ✅ 2020-12, draft-07, draft-04, OpenAPI 3.0 | ✅ draft-07, 2020-12, OpenAPI 3.0 (@valibot/to-json-schema) | ✅ draft 2020-12, draft-07 | ✅ 2020-12, draft-07, draft-04, OpenAPI 3.1 | ❌ none | ❌ none | ❌ none | ❌ none | ✅ 2020-12 via ~standard.jsonSchema | ✅ draft-07 via toJSONSchema() | ✅ the schema is JSON Schema | ✅ types are JSON Schema (2020-12) | ✅ json.schema(s): OpenAPI 3.0, 3.1 | ❌ none | ❌ none | ✅ toJSONSchema(): JSON Schema, $jsonSchema | |||
| JSON Schema import | ✅ fromJSONSchema (a subset) | ❌ none | ✅ z.fromJSONSchema62 | ✅ SchemaRepresentation.fromJsonSchemaDocument | ❌ none | ❌ none | ❌ none | ❌ none | ❌ none | ❌ none | ❌ none | ✅ draft-07, 2019-09, 2020-12, JTD | ✅ Compile/Value accept drafts 3–2020-12 | ❌ none | ❌ none | ❌ none | |||||
| LLM structured-output profile | ✅ the strict profile | ❌ none | ❌ none | ✅ effect/ai toCodecOpenAI, toCodecAnthropic63 | ❌ none | ❌ none | ❌ none | ❌ none | ❌ none | ❌ none | ❌ none | ❌ none | ❌ none | ❌ none | ❌ none | ✅ llm.application/parameters; strict mode | ❌ none | ❌ none | ❌ none | ||
| Domain models | |||||||||||||||||||||
| Entities with storage metadata | ✅ kinds: Entity, Registry, … | ✅ 11 kinds: scope, store, id field | ❌ none | ❌ none | ❌ none | ❌ none | ❌ none | ❌ none | ❌ none | ❌ none | ❌ none | ❌ none | ❌ none | ❌ none | ❌ none | ❌ none | ❌ none | ❌ none | ❌ none | ✅ model(name), collection option | |
| References: an id or the record | ✅ an id or the record, by kind or per call | ❌ none | ❌ none | ❌ none | ❌ none | ❌ none | ❌ none | ❌ none | ❌ none | ❌ none | ❌ none | ❌ none | ❌ none | ❌ none | ❌ none | ❌ none | ❌ none | ❌ none (named types embed) | ❌ none (messages embed) | ||
| Indexes and unique keys | ✅ index, primary, unique; driver specs (./mongodb) | ✅ index/primary/unique arrays | ❌ none | ❌ none | ❌ none | ❌ none | ❌ none | ❌ none | ❌ none | ❌ none | ❌ none | ❌ none | ❌ none | ❌ none | ❌ none | ❌ none | ❌ none | ❌ none | ❌ none | ❌ none | ✅ index/unique/sparse, schema.index() |
| Relation metadata | ✅ schema.relations | ❌ none | ❌ none | ❌ none | ❌ none | ❌ none | ❌ none | ❌ none | ❌ none | ❌ none | ❌ none | ❌ none | ❌ none | ❌ none | ❌ none | ❌ none | ❌ none | ❌ none | ❌ none | ||
| Derive a schema: pick, omit, partial, extend | ✅ pick, omit, partial (deep), required, extend, keyof (./derive) | ✅ pick, omit, partial, required, extend, deepPartial | ✅ pick, omit, partial, required | ✅ pick, omit, partial, required, merge | ✅ mapFields + Struct.pick/omit/map | ✅ pick, omit, partial, assign | ✅ pick, omit, extend, asPartial | ✅ pick, omit, partial (deep), required | ✅ pick, omit, partial, extend, rest | ✅ pick, omit, partial, shape, concat | ✅ keys, append, fork, tailor, concat | ✅ pick, omit, partial, merge(group) | ✅ Pick, Omit, Partial, Required, Mapped | ✅ TS Pick, Omit, Partial | ❌ none | ❌ none | ✅ pick, omit, add, clone, discriminator | ||||
| A model of many schemas | ✅ Model: order, missing references | ✅ Model: order, missing refs | ❌ none | ❌ none | ❌ none | ❌ none | ❌ none | ❌ none | ❌ none | ❌ none | ❌ none | ❌ none | ❌ none | ❌ none | ❌ none | ||||||
| Evolution | |||||||||||||||||||||
| Snapshot of a schema or model | ✅ model.toJSON(), schema.toSnapshot(), fromJSON | ❌ export only, no restore | ✅ .json → type.schema() | ✅ SchemaRepresentation toJson / fromJson | ❌ none | ❌ none | ❌ none | ❌ none | ❌ none | ✅ describe() / Joi.build() | ❌ none | ✅ schemas are JSON | ✅ JSON.stringify; Compile reads it back | ✅ schema(), JSON.stringify(type) | ✅ root.toJSON() / Root.fromJSON() | ❌ none | |||||
| Diff of two versions | ✅ diff | 🔜 issue #251 (migrations) | ❌ none | ❌ none | ❌ none | ❌ none | ❌ none | ❌ none | ❌ none | ❌ none | ❌ none | ❌ none | ❌ none | ❌ none | ❌ none | ❌ none | ❌ none | ❌ none | ❌ none | ❌ none | |
| Compatibility of a change | ✅ backward / forward / full | ❌ none | ❌ none | ❌ none | ❌ none | ❌ none | ❌ none | ❌ none | ❌ none | ❌ none | ❌ none | ❌ none | ❌ none | ❌ none | ❌ none | ❌ none | ✅ reader/writer resolution67 | ❌ none | ❌ none | ||
| Field aliases (renames) | ✅ aliases, renamed by parse | ❌ none | ❌ none | ❌ none | ❌ none | ❌ none | ❌ none | ❌ none | ❌ none | ❌ none | ✅ object.from(old, new, alias) | ✅ rename(old, new, { alias }) | ❌ none | ❌ none | ❌ none | ❌ none | ✅ type and field aliases | ✅ alias option (virtual) | |||
| Deprecation markers | ✅ deprecated | ❌ none | ✅ .meta({ deprecated: true }) | ❌ free-form metadata() only | ✅ .configure({ deprecated: true }) | ❌ none | ✅ deprecated(struct, log) | ❌ none | ❌ none | ❌ none | ❌ none | ❌ none (meta() is free-form) | ❌ none (meta(), notes free-form) | ❌ none | ❌ none | ✅ deprecated keyword (annotation) | ✅ JSDoc @deprecated → schema | ❌ none | ❌ none | ||
| Tooling | |||||||||||||||||||||
| Sample values from a schema | ✅ sample, seeded | ❌ none | ✅ @ark/fast-check (arkToArbitrary) | ✅ Arbitrary.schema (own engine) | ❌ none | ❌ none | ❌ none | ❌ none | ❌ none | ❌ none (example() is an annotation) | ❌ none | ❌ none | ✅ typia.random<T>() (randexp) | ✅ type.random() | ❌ none | ❌ none | |||||
| A schema inferred from data | ✅ infer | ❌ none | ❌ none | ❌ none | ❌ none | ❌ none | ❌ none | ❌ none | ❌ none | ❌ none | ❌ none | ❌ none | ❌ none | ❌ none | ❌ none | ❌ none | ❌ none | ✅ Type.forValue, Type.forTypes68 | ❌ none | ❌ none | |
| Merge or intersect two schemas | ✅ merge | ❌ none | ✅ z.intersection, .extend | ✅ intersect | ✅ .merge(), .and() | ✅ assign(), intersection() | ✅ intersection() | ✅ Intersect(...), and() | ✅ and() / intersection() | ✅ concat() | ✅ concat() | ❌ none | ❌ none | ✅ Intersect + Evaluate | ✅ TS intersection (A & B) | ❌ none | ✅ schema.add(other), new Schema([a, b]) | ||||
| Warnings about a definition | ✅ schema.warnings | ❌ none | ❌ none | ❌ none | ❌ none | ❌ none | ❌ none | ❌ none | ❌ none | ❌ none | ❌ none | ❌ none | ❌ none | ❌ none | ✅ strict mode; strictTypes logs | ❌ none | ❌ compile errors only | ❌ none | ❌ none | ||
| Streams and iterables of records | ✅ checkEach, transform | ❌ none | ❌ none | ❌ none | ❌ none | ✅ ChannelSchema.decode/encode69 | ❌ none | ❌ none | ❌ none | ❌ none | ❌ none | ❌ none | ❌ none | ❌ none | ❌ none | ❌ none | ❌ none | ❌ none | ❌ none (delimited framing only) | ❌ none | |
| Compiled to code, with a fallback | ✅ compile(), closures fallback | ❌ interpreter only | ✅ z.compile / zod/compile; runtime fallback | 🔜 compiler PR by the author (#1441) | ✅ JIT by default; jitless fallback | ✅ opt-in JIT + AOT; interpreter fallback70 | ❌ interpreter only | ❌ interpreter only | ❌ interpreter only | ❌ interpreter only | ❌ interpreter only | ❌ interpreter only | ❌ interpreter only | ✅ always; no interpreter fallback | ❌ interpreter only | ✅ always compiles; no interpreter | ✅ Compile; interpreter fallback | ✅ AOT at build time; no fallback | ✅ new Function; no fallback | ✅ runtime codegen; static AOT | ❌ interpreter only |
| Readable at runtime | ✅ fields, toJSON() | ✅ fields, kind, indexes, relations | ✅ .def, .shape, .meta() | ✅ plain schema objects (type, entries, pipe) | ✅ .json, .props, .expression, .select() | ✅ .ast, .fields, annotations | ✅ _tag, props, types, name | ✅ tag, fields, alternatives, element | ✅ describe(), fields, spec | ✅ describe(), $_terms, _rules | ✅ toJSON() tree, getProperties() | ✅ getMetadataStorage() | ✅ the schema is a plain object | ✅ JSON Schema objects; Type.Is* guards | ✅ reflect.schema / json.schema | ✅ fields, typeName, schema() | ✅ reflection: fields, options | ✅ schema.paths, path(), eachPath | |||
✅ built in, or in an official package of the same project (named) ·
- No release since 2024-07; MetaMask publishes a maintained fork,
@metamask/superstruct. - No commits since the 2025-02 release.
- The legacy @sinclair/typebox 0.34.52 differs materially: CJS + ESM, Type.Date / Uint8Array and a TypeRegistry for custom kinds, and a Value.Parse that cleans, defaults and converts by default (research/typebox-legacy/node_modules/@sinclair/typebox/build/cjs/value/parse/parse.js:60-66).
- A minified bundle validates nothing but unknown keys: identity is checked by constructor.name === 'Type' (lib/util.js:42, lib/struct.js:34), which minifiers rename. esbuild minify without keepNames: { a: 1 } against { a: 'string' } is valid.
- The browser bundle has no describe()/build() ('Manifest functionality disabled'), binary() or trace(); the main entry needs Node's url and util modules.
- The compiled validator needs code generation, so it does not run on Cloudflare Workers or under a CSP without unsafe-eval.
- Runtimes that forbid eval (Cloudflare Workers, strict CSP) can only run validation code pre-generated with ajv/dist/standalone at build time.
- Built for browserify-era Node polyfills; and since every type is created with new Function, eval-free edge runtimes (Cloudflare Workers) cannot load it.
- @mongoosejs/browser 0.1.3 is a prebuilt UMD (1.1 MB, 270 KB gzip) of mongoose 9.0.0-rc1 that validates documents only; its Node entry exports the driver, not mongoose, despite its README.
- z.object builds a fast path with new Function when eval is allowed; a CSP probe (or z.config({ jitless: true })) switches to the interpreter. z.compile / "zod/compile" also use new Function and fall back to the runtime parser.
- Standard (TC39) decorators are not supported; class-transformer's implicit conversion also needs emitDecoratorMetadata.
- Since v13 the transform is written in Go and run by ttsc on the TypeScript 7 native compiler (the first build compiled the plugin in 85 s); typia 12 is the legacy ts-patch setup for TypeScript < 7. Bundlers need @ttsc/unplugin.
- After registerSchema(), validating any decorated class throws the same TypeError (MetadataStorage.js:107).
- optional() lets null through and drops it from the output; nullable() keeps the key required; an absent key and undefined are the same.
- A longer array is accepted and truncated, not rejected.
- The invalid_length message is garbled in 0.5.5 ("expected an arraywith items", :852-866): a missing space and no count.
- Regex-only: uuid() accepts versions 1-5 only (a v7 UUID fails) and datetime() does not check ranges.
- The core has none: in strict mode a schema with an unknown format fails to compile ("unknown format "email" ignored in schema").
- An unregistered format name passes silently: Format.Test returns true when the name is unknown (_registry.ts:103-106; typebox-behaviour.mjs).
- Type.Date and Type.Uint8Array were removed in 1.0; the migration guide rebuilds them with Type.Unsafe + Type.Refine.
- An invalid Date (new Date("nope")) passes as a Date, and NaN passes as a number unless the numeric/finite transform flags are set.
- A JS Map passes a map type vacuously (it has no own keys): new Map([['a', 'x']]) is valid for values int and encodes as an empty map (lib/types.js:1799-1830).
- verify() rejects a BigInt for int64 ("integer|Long expected") although fromObject and toObject accept it.
- A field validate runs even when the type check already failed (both messages reported).
- Async needs the parallel *Async API (objectAsync, pipeAsync, checkAsync). In plain JS, the sync safeParse of an async schema reports success: true for invalid input (the Promise has no issues); TypeScript forbids the call.
- A narrow that returns a Promise passes, because the predicate result is only tested for truthiness.
- TaskDecoder belongs to the experimental modules (lib/TaskDecoder.d.ts:2, "independent and backward-incompatible with stable ones").
- An async predicate silently passes every value (its promise is truthy).
- An async predicate silently passes every value.
- custom() given an async function passes and returns a Promise as the value (research-scripts/joi-extra.mjs).
- validateSync() skips async validators silently and reports the value valid (Validator.js:31; script).
- A refinement that returns a promise is not awaited: Value.Check returns true, and a compiled Check returns the Promise object itself, so the value is accepted.
- The Ajv security guide says not to use allErrors: true in production (research/ajv-src/docs/security.md:41).
- An unknown nested key loses its path: { nested: { extra } } reports Field "extra" (research-scripts/metarhia-basics.mjs).
- In @valibot/i18n 1.3.0 the CommonJS root entry does not load (index.cjs declares
var import_zh-CN, a SyntaxError); ESM and per-locale imports work. - Keys named like Struct.prototype members (constructor, check, proto) pass silently: this[name] finds the inherited member (lib/struct.js:29-34).
- The same schema keeps unknown keys under check()/guard() and drops them under parse().
- noUnknown() strips unknown keys instead of rejecting them unless the schema is strict() (the README warns about it); exact() rejects.
- No way to reject unknown keys.
- whitelist deletes the keys from the validated object itself; forbidUnknownValues (default true) rejects an object with no metadata, so a plain object fails.
- equals accepts an extra key whose value is undefined.
- Each policy belongs to a different API: verify ignores, fromObject strips, ProtoJSON fromJson rejects by default.
check(null)throws.- validateSync is deprecated in 9.x and emits a process warning on every call (research-scripts/mongoose-custom.mjs); validate() rejects; the constructor throws for strict: 'throw' and cyclic input.
- The classic API installs a memoizer: a self-referencing value parses (output keeps the cycle), unless the cycle closes through a transform ($ZodCyclicError). zod/mini has no memoizer unless configured. 10 000-deep input overflows the stack.
- The RangeError surfaces as a defect: the Result/Exit adapters throw "Result adapter can only return schema issues".
- The RangeError of a cyclic or 3 000-deep value is returned as an issue whose path is thousands of keys long.
- A cycle is accepted (coinductive), and there is no depth limit.
- Defaults apply only on the coercing calls (create, mask, validate with coerce: true), not in is()/assert().
- The default is a value, not a factory: default([]) hands every parse the same array (script).
- Defaults are written into the input object, and not inside anyOf/oneOf/not.
- Value.Parse and Compile().Parse never fill defaults (only with the global correctiveParse setting, and only for a value that fails the check); Value.Decode does, and Compile().Decode does only when the type contains a codec.
- Opt-in, in parse only (check never converts): strict decimal strings,
true/false/1/0, ISO dates with a zone, never the empty string; a value a union branch accepts as it is is not converted;coerce()is the step alone. - Lenient: number().coerce() turns "1abc" into 1, number() accepts NaN, bigint() accepts true (script).
- number() also rejects values beyond Number.MAX_SAFE_INTEGER unless unsafe() (types/number.js:104).
- fromObject does not validate: { age: 'abc' } becomes 0 and { name: 42 } "42".
- Only number/string/boolean are typed: a nested struct, enum, array or custom type is rendered as string (profile: string), and a reference as <name>Id: string although check validates the embedded record.
- Schemas do not carry ~standard themselves ('~standard' in schema is false); wrap with Schema.toStandardSchemaV1 / toStandardJSONSchemaV1.
- ~standard.validate always returns a promise and runs with abortEarly: false; array indexes come back as strings.
- ~standard.validate puts the dotted field string in path (the specification wants an array) and, for a valid falsy output (false, 0, ''), returns { issues: undefined } without the value. Schemas themselves have no ~standard; vine.create() validators do.
- On models only (a Schema has none), and validate always returns a promise; it casts, so { n: '5' } is valid with value n: 5.
- Its JSDoc calls it semi-experimental.
- Marked @stability unstable.
- An id is never accepted, while the generated dts names the field companyId: string.
- A populated document is not validated with its own schema (an invalid Dog passes).
- The labels are reversed: a many field is labelled many-to-one and a single reference one-to-many.
- A resolver either builds or throws for one reader/writer pair; there is no backward/forward/full classification.
- Any non-integer number below 2^53 is inferred as float (32-bit), which loses precision when encoded (lib/types.js:247-252).
- Works on Effect Channels/Streams (unstable), not on Web Streams directly.
- Both are @stability unstable; the interpreter is the default.
pnpm add @alexify/metaschema
# or
npm install @alexify/metaschemaRequires Node.js 18 or newer. Works in browsers through any bundler. The typings need TypeScript 5.0 or newer.
| Form | Example | Meaning |
|---|---|---|
| Type name | title: 'string' |
a required string |
| Optional | subtitle: '?string' or 'subtitle?': 'string' |
the key may be absent, undefined or null |
| Nullable | parent: { type: 'string', nullable: true } |
the key is required, the value may be null |
| Long form | login: { type: 'string', length: [3, 32] } |
a type with options, type first |
| Rules | { type: 'integer', min: 18, max: 120 }, { type: 'string', pattern: '^[a-z]+$', length: { max: 32 } }, { type: 'number', decimals: 2 } |
min/max for numbers, decimals for a number, length/pattern/format for strings, length for collections |
| Collections | { array: 'number' }, { set: 'string' }, { object: { string: 'number' } }, { map: { string: 'string' } } |
|
| Enum | { enum: ['admin', 'user'] } |
one of the values |
| Flags | perms: { flags: ['read', 'write'] } |
an object of those names, each true or false |
| Bytes | avatar: 'bytes' |
a Uint8Array (a Buffer too) |
| Tuple | point: ['number', 'number'] |
a fixed-length array, each element its own definition |
| Union | id: { union: ['string', 'number'] }, { union: [...], discriminator: 'kind' } |
one of several definitions |
| Nested struct | name: { first: 'string', last: 'string' } |
an object whose first key is none of the above |
| Reference | company: 'Company', { many: 'Address' } |
another schema in the model (or the schema itself, by its own name): its id for a stored kind, the record for a memory kind |
| Any object | payload: 'json' |
any non-null object |
| Calculated | ratio: (file) => file.compressed / file.size |
a function, never validated |
The first key of an object decides what it is. Built-in types: string, number, integer,
bigint, boolean, date, bytes, null, any/unknown, enum, flags, array, set,
object, map, tuple, union, json. Rules: length (with unicode: true for code
points), pattern and format for strings, length for collections, min/max for numbers,
decimals for a number; a rule on a type that does not
take it is a definition error. Fields can add a validate(value, path) function, and a schema
can have a top-level validate for rules across fields. check(value, options) takes root
(the label of the error lines), maxErrors, unknown: 'ignore' (keys the schema does not have;
{ Struct: { unknown: 'ignore' } } makes it the schema's default), references ('kind',
'embed' or 'id') and messages (a locale); the result has issues, errors, summary,
flatten(), tree() and toJSON(). schema.warnings lints the definition (a mistyped option, a pattern
without length.max). See Schema Syntax,
Types,
Unions, nullable and null,
References and
Validation.
const { Model } = require('@alexify/metaschema');
const types = {
string: { metadata: { pg: 'varchar' } },
number: { metadata: { pg: 'integer' } },
boolean: { metadata: { pg: 'boolean' } },
};
const entities = new Map([
['Company', { Dictionary: {}, name: { type: 'string', unique: true }, addresses: { many: 'Address' } }],
['Address', { Entity: {}, city: 'string', street: 'string', building: '?string' }],
['User', { Registry: {}, login: { type: 'string', length: { min: 3, max: 32 } }, company: 'Company', active: 'boolean' }],
]);
const model = new Model(types, entities);
model.order; // Set { 'Address', 'Company', 'User' }
model.warnings; // [] (lint and consistency warnings, `Warning [code]: text`, end up here)
model.entities.get('User').check({ login: 'ab', company: 'c1', active: true }).errors;
// [ 'Field "User.login" value is too short' ]
const { toTypeScript } = require('@alexify/metaschema/generators');
console.log(toTypeScript(model));interface Address {
city: string;
street: string;
building?: string;
addressId?: string;
}
interface Company {
name: string;
addressesId: string[];
companyId?: string;
}
interface User {
login: string;
companyId: string;
active: boolean;
userId?: string;
}saveTypes(outputFile, model, options?) of the same subpath writes the interfaces to a file
({ named: true } names enums and nested structs, and a field's description is its JSDoc); its
register() adds model.dts, model.toTypeScript and schema.toInterface as methods. The first
key of a definition sets
its kind and metadata (Entity, Registry, Dictionary, Journal, Details, Relation,
View, Struct, Form, Projection, or any custom kind). Stored kinds default to
scope: 'application' and store: 'persistent', and they get an id field; a reference to a
stored kind holds its id, a reference to a memory kind embeds the record, in check and in the
generated types alike. See
Kinds and Metadata,
Custom Types and
Domain Models.
format names a form a string must have; @alexify/metaschema/formats registers the 35
built-in formats and your own, once per process:
const { register } = require('@alexify/metaschema/formats');
register({ phone: { test: (value) => /^\+\d{9,15}$/.test(value), examples: ['+380501234567'] } });
const signup = Schema.from({
email: { type: 'string', format: 'email', length: { max: 254 } },
phone: { type: 'string', format: 'phone' },
});
signup.check({ email: 'ann@', phone: '050' }).errors;
// [ 'Field "email" is not a valid email', 'Field "phone" is not a valid phone' ]A format the table does not know is a definition error, never a check that passes everything;
toJSONSchema writes the name, fromJSONSchema reads it back, sample draws the format's
examples and infer reads a format out of the strings. See
String formats.
@alexify/metaschema/stream runs a schema over a sequence of records, lazily and in order:
const { checkEach, parseEach, transform } = require('@alexify/metaschema/stream');
for (const { index, record, result } of checkEach(user, rows)) {
if (!result.valid) console.log(index, result.errors);
}
for (const { result } of parseEach(user, rows, { unknown: 'strip', maxInvalid: 10 })) save(result.value);
const parsed = ReadableStream.from(rows).pipeThrough(
transform(user, { onInvalid: (record, result, index) => log(index, result.issues) }),
);checkEachAsync and parseEachAsync take a sync or async source and keep concurrency records
in flight; maxInvalid ends an iteration after that many invalid records, and a transform
without onInvalid ends its stream with ERR_INVALID_RECORD. See
Records and streams.
Text from a query string, a form or a file converts to the types of the fields with
coerce: true, before the check; check never converts:
require('@alexify/metaschema/coerce').register();
const Query = new Schema('Query', {
Form: { coerce: true },
page: { type: 'integer', min: 1 },
all: 'boolean',
tags: { array: 'string' },
});
Query.parse(Object.fromEntries(new URLSearchParams('page=2&all=1&tags=news'))).value;
// { page: 2, all: true, tags: [ 'news' ] }The converters are strict (a number written as one, true/false/1/0, an ISO date with
its zone, never the empty string), a union takes a value a branch accepts as it is, and
coerce(schema, value) converts without checking. See
Coercion.
const { omit, partial, pick } = require('@alexify/metaschema/derive');
const CreateAccount = omit(Account, ['password'], { name: 'CreateAccount' });
const UpdateAccount = partial(CreateAccount, { name: 'UpdateAccount' });
const AccountView = pick(Account, ['accountId', 'login', 'role'], { name: 'AccountView' });A derived schema is built from the definition of its source, every rule of a field included; a
stored kind becomes a struct without its id, partial leaves the defaults out (a patch keeps
what is stored), extend replaces fields where merge unifies them, and register() makes the
six methods (Account.omit(['password']).partial()). See
Deriving schemas.
const mongodb = require('mongodb');
const bson = require('@alexify/metaschema/mongodb');
bson.register({ bson: mongodb });
const model = new Model({ ...bson.types }, entities);
const validators = toJSONSchema(model, { target: 'mongodb', ids: 'objectId' });
const indexes = bson.indexesOf(model); // for createIndexes
const parsed = Account.parse(body);
if (parsed.valid) await accounts.insertOne(bson.toDocument(Account, parsed.value));
const { value } = bson.fromDocument(Account, await accounts.findOne({ login: 'ann' }));
if (bson.checkUpdate(Account, update).valid) await accounts.updateOne(filter, bson.toUpdate(Account, update));The program keeps ids as strings, the database as ObjectIds under _id; the subpath does no I/O
and imports nothing of the driver. Mongoose remains the tool for populate, middleware and
change tracking. See MongoDB.
infer of @alexify/metaschema/model reads a definition out of the values that exist, the
reverse of sample, to bootstrap a model from the documents of a collection or the rows of a
table:
const { infer } = require('@alexify/metaschema/model');
infer([
{ id: 'ord-1', total: 12.5, status: 'paid', address: { city: 'Kyiv', zip: '01001' } },
{ id: 'ord-2', total: 7, status: 'new', address: { city: 'Lviv', zip: null }, coupon: 'X1' },
]);
// {
// Struct: {},
// id: 'string',
// total: 'number',
// status: 'string',
// address: { city: 'string', zip: { type: 'string', nullable: true } },
// coupon: '?string',
// }Records fuse into one struct with optional keys, a string of few recurring values is an enum,
records whose keys are data are a dictionary, and the definition is checked against every
sample before it comes back. See
Schema inference.
A schema keeps its definition as a type parameter, and InferSchema turns it into the type of
a value check accepts; Infer<typeof definition> does the same for a definition declared
as const:
import { Schema, type InferSchema } from '@alexify/metaschema';
const user = Schema.from({
name: 'string',
age: '?number',
role: { enum: ['admin', 'user'] },
tags: { array: 'string' },
address: { city: 'string', 'street?': 'string' },
point: ['number', 'number'],
});
type User = InferSchema<typeof user>;
// {
// name: string;
// age?: number | null | undefined;
// role: 'admin' | 'user';
// tags: string[];
// address: { city: string; street?: string | null | undefined };
// point: [number, number];
// }Every form of the language is covered: optional and nullable fields, collections, enums,
tuples, unions, nested structs, references (an id, or the record through an entity map with
Infer<D, E> and InferEntity<E, 'Name'>) and custom types declared through module
augmentation of CustomTypes. A model renders its entities as interfaces with toTypeScript. See
TypeScript.
A Schema<D> is also a StandardSchemaV1<Infer<D>, InferOutput<D>> of @standard-schema/spec:
schema['~standard'] is typed as StandardProps<D>, with StandardResult<T> and
StandardOptions beside it. See
Standard Schema.
toJSONSchema(schema, options) of @alexify/metaschema/generators renders the rules of check
as a JSON Schema document, and toJSONSchema(model, options) every entity of a model as a
definition:
const { toJSONSchema } = require('@alexify/metaschema/generators');
toJSONSchema(user, { target: 'draft-07' });
// {
// $schema: 'http://json-schema.org/draft-07/schema#',
// type: 'object',
// properties: {
// name: { type: 'object', properties: { first: { type: 'string' }, last: { type: 'string' } }, required: ['first', 'last'], additionalProperties: false },
// email: { type: 'string', minLength: 5, maxLength: 64 },
// age: { type: ['number', 'null'] },
// roles: { type: 'array', items: { enum: ['admin', 'editor', 'viewer'] } }
// },
// required: ['name', 'email', 'roles'],
// additionalProperties: false
// }
toJSONSchema(answer, { profile: 'strict' }); // the dialect of OpenAI and Anthropic structured outputs
toJSONSchema(model, { target: 'mongodb' }); // one $jsonSchema validator per entityThe targets are draft-2020-12 (the default), draft-07, openapi-3.0 and mongodb; an
optional field accepts null, a reference to a stored kind is its id and a reference to a memory
kind a $ref, validate functions are not exported, and a type with no form in the target
(bigint, a Date on the way out) throws unless the call says unrepresentable: 'any'. The
export is checked against ajv for every form of the language. register() of the subpath turns
the functions into methods (schema.toJSONSchema, model.toJSONSchema) and adds the Standard
JSON Schema converter to schema['~standard']. See
JSON Schema.
compile(schema) of @alexify/metaschema/compile replaces the check of a schema, or of every
entity of a model, with one function generated from the same plan the closures walk and built
with new Function; register() does it for every schema built from then on. The issues are
the ones the closures record, only sooner: about three times faster on a flat struct, six on a
nested one. Where code generation from strings is not allowed (a Content Security Policy,
Cloudflare Workers, node --disallow-code-generation-from-strings) nothing changes: the
closures stay, silently unless { jit: true } asks for an error.
const { compile, compiled, register, canEval } = require('@alexify/metaschema/compile');
compile(user); // { accelerated: true }: user.check runs generated code now
compiled(user); // true
compile(model); // every entity; { accelerated: false } where there is no eval
register(); // every schema built from here on is compiled as it is built
canEval(); // whether this environment builds functions from sourceThe generated source holds identifiers, numbers and JSON strings only; regular expressions, enum sets, validate functions and nested schemas reach it through a scope, never through the source text. See The JIT backend.
const {
KIND, KIND_STORED, KIND_MEMORY, SCOPE, STORE, ALLOW,
getKindMetadata, Schema, Model, SchemaDefinitionError, ValidationResult,
} = require('@alexify/metaschema');
const { toJSONSchema, toTypeScript, saveTypes, fromJSONSchema, register } = require('@alexify/metaschema/generators');
const { compile, compiled, canEval } = require('@alexify/metaschema/compile'); // and its own register()
const { fromJSON, diff, compatibility, compatibilityOf, sample, merge, infer } = require('@alexify/metaschema/model'); // and its own register()
const { checkEach, parseEach, checkEachAsync, parseEachAsync, transform } = require('@alexify/metaschema/stream'); // and its own register()
const { FORMATS, register: registerFormats } = require('@alexify/metaschema/formats'); // fills the table the format rule reads
const { pick, omit, partial, required, extend, keyof } = require('@alexify/metaschema/derive'); // and its own register()
const { COERCIONS, coerce, register: registerCoercion } = require('@alexify/metaschema/coerce'); // the converters parse runs
const { types, toDocument, fromDocument, fromDocumentAsync, indexesOf, checkUpdate, toUpdate } = require('@alexify/metaschema/mongodb'); // and register({ bson })
const { Schema: Definition } = require('@alexify/metaschema/definition'); // the core's Schema and Model, built and read without validation
const en = require('@alexify/metaschema/locales/en'); // the built-in messages
const uk = require('@alexify/metaschema/locales/uk'); // schema.check(value, { messages: uk })import { Schema, Model } from '@alexify/metaschema';
import type { Infer, InferSchema, InferEntity, CustomTypes, ValidationIssue } from '@alexify/metaschema';In the browser every export works the same, except saveTypes of the generators, which rejects
because there is no file system, and compile, which keeps the closures where a Content
Security Policy forbids new Function. The API reference lists every member
and every exported type.
3.0 has two breaking changes. The generators left the core entry: model.dts,
schema.toInterface() and saveTypes are toTypeScript(target, options) and
saveTypes(file, target, options) of @alexify/metaschema/generators, whose register() puts
the methods back on the classes once per process. And schema['~standard'].validate is parse
rather than check: a consumer of Standard Schema gets the defaults filled in and the parse
functions applied, and a promise for a schema with an async function. The smaller changes: a
struct rejects an array, the synchronous check and parse throw ERR_ASYNC_VALIDATOR for a
promise, a graph deeper than 128 embedded records is a depth issue (maxDepth raises it),
Infer makes a defaulted key optional, and a few definitions that could never work throw when
the schema is built. Everything else is additive: parse, serialize, checkAsync and
parseAsync, the JSON Schema export, the JIT backend of @alexify/metaschema/compile, JSDoc
and named types. 3.1 and 3.2 add without breaking, with a few behaviours a 2.x program meets on
the way: two fields of one key and a non-list aliases are definition errors,
JSON.stringify(model) writes the snapshot, the issues of an async check come in walk order
then field order, a pattern is not run past length.max, and the unexpected message lists
ten keys. The step-by-step guide with before-and-after examples is
Migrating from 2.x; the full list is
under "Upgrading from 2.0" in the CHANGELOG.
2.0 changes the shape of a result (issue.path is an array of keys, issue.message has no
location, unknown keys are one issue per struct), the signature of check
(check(value, { root, maxErrors, unknown, references, messages })), and a few rules of the
language: the first key of an object decides what it is, numbers are bounded by min/max
instead of length, a reference to a stored kind is its id, a set renders as Set<T>, the
relations labels follow the referencing side, and a type failure cancels the rules and
validate of the field. Everything else is additive: integer, date, null, any, union,
nullable, pattern, locales, the lint, Infer and Standard Schema. The step-by-step guide
with before-and-after examples is
Migrating from 1.x; the full list is
under "Upgrading from 1.x" in the CHANGELOG.
@alexify/metaschema is a fork of metaschema 2.2 with
the same schema language. What changed in 1.0:
- No loader.
createSchema,loadSchema,readDirectoryandloadModelare removed along with themetavmsandbox. Usenew Schema(name, require('./schemas/User.js'))andnew Model(types, new Map([...]), database). Schema files written as({ ... })become modules (module.exports = { ... }). - No runtime dependencies:
metautil,metavmandmetaskillsare gone. - Fixed messages, no old text kept:
Field "..."instead ofFiled "..."andnot of expected type: objectinstead ofis not a object. detouchis renamed todetach, with no alias.browser.jsreplacesdist.js, and anexportsmap closes deep imports.
The full list is in Migrating from metarhia, and the 2.0 and 3.0 changes above apply on top of it.
See CHANGELOG.md.
MIT © Alexis Technologies
@alexify/metaschema is a fork of metaschema by the
Metarhia contributors, originally
part of the Metarhia technology stack. Their copyright is kept in
LICENSE.