Skip to content

Compared with alternatives ​

metaschema sits where four kinds of tools meet: the TypeScript-first validators (zod, Valibot, ArkType, Effect Schema and their kin), the JSON Schema validators and ahead-of-time compilers (Ajv, TypeBox, typia), the interface definition languages built for schema evolution (Avro, Protocol Buffers, and Metarhia's metaschema, which this project was forked from) and the schemas of an object-document mapper (Mongoose). The table below compares it with twenty of them, every feature in one table, rather than a few convenient libraries in a few convenient rows.

What the table shows ​

  • One definition, the whole lifecycle. Effect Schema comes closest in breadth (codecs, JSON Schema both ways, samples, streams, an LLM profile), and Mongoose and the IDLs have parts of a domain model; only metaschema is a validator with inferred types whose schemas are also entities with references (an id or the record), indexes and relations, assembled into a model with a snapshot, a diff of two versions and a compatibility class for the change.
  • Every issue as data is not the norm. Seven of the twenty-one report a code, the path as keys and the parameters of each issue; the rest give a JSON Pointer, a dotted string or a message. Eight stop at the first issue unless told otherwise (protobuf.js always does), and only metaschema takes a cap per call (maxErrors); TypeBox has one for the whole process.
  • An async validator can let bad data through. In ArkType, myzod, Valita and TypeBox a validator that returns a promise passes, because a promise is truthy; Joi's custom(), Valibot's synchronous safeParse of an async schema and class-validator's validateSync do the same. metaschema throws ERR_ASYNC_VALIDATOR from the synchronous methods.
  • Cyclic or very deep input overflows the stack in twelve of them. metaschema tracks cycles where a record is embedded and stops at maxDepth; Joi and myzod turn the overflow into an issue, and protobuf.js has a depth limit.
  • Code generation is a trade. VineJS and avsc cannot run where new Function is forbidden (a strict Content Security Policy, Cloudflare Workers), Ajv needs code generated at build time there, and Zod, ArkType and TypeBox generate code by default and fall back to an interpreter. metaschema generates nothing unless compile() is asked, and falls back the same way.
  • Some are no longer moving. Superstruct (last release 2024-07, with a maintained fork by MetaMask), io-ts (2024-12) and myzod (2025-02).

How the table was made ​

  • From the code, not the README. Each library was installed at its latest version from npm (October 2026) and every cell decided by reading its source, and where the behaviour was not obvious from reading (the default of error collection, unknown keys, a cyclic value, whether a format exists), by running a script against it. A README claim the code does not back is not counted, and the footnotes say where the two disagree.
  • Built in or official. A feature counts as ✅ when the package has it or an official package of the same project does (named in the cell); a third-party package is ⚠️ and named. A ❌ means it is not built in: most of them can be written by hand on top.
  • The same questions for everyone. The rows were fixed before any column was filled, and a row that does not apply to a kind of tool is marked — rather than ❌.
  • metaschema's gaps are shown as such. Its JIT needs new Function (it falls back to the closures without it), coercion is opt-in where most validators of forms cast by default, and the MongoDB subpath is the schema side of Mongoose, not an ODM.

The speed of the libraries that can be benchmarked side by side is on the Benchmarks page, with the method; this page is about what each one does.

The matrix ​

Featuremetaschema
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 compared3.4.02.2.24.6.61.5.02.2.84.0.32.0.212.2.227.0.51.12.120.5.51.7.118.2.94.4.00.15.18.20.01.3.36315.1.05.7.98.8.19.11.1
Latest release2026-10 (this release)2025-052026-102026-092026-102026-102024-072024-122026-082025-022026-102025-092026-092026-042026-022026-042026-102026-102025-072026-102026-10
Runtime dependencies✅ 02: metautil, metavm✅ 0✅ 03: @ark/schema, @ark/util, arkregex✅ 0✅ 00 + peer fp-ts✅ 0✅ 0✅ 04: property-expr, tiny-case, toposort, type-fest7 (@hapi/*, …)10 (validator.js, dayjs, …)3 (validator.js, libphonenumber-js, …)4✅ 03: randexp, @typia/interface, @typia/utils✅ 01: long7, incl. mongodb driver
LicenseMITMITMITMITMITMITMITMITMITMITMITMITBSD-3-ClauseMITMITMITMITMITMITBSD-3-ClauseMIT
Runtime
Runs on✅ Node 18+, browsers, edge⚠️ Node; browsers via bundler4✅ 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; browsers/edge via a bundler✅ Node ≥22, Deno (JSR), browsers, edge✅ Node, browsers, Deno, Bun, edge⚠️ Node ≥ 20; browsers via a separate bundle5⚠️ Node, Bun, Deno; browsers if eval is allowed6✅ Node, browsers, Deno, Bun, edge⚠️ Node, browsers; CSP/edge need standalone7✅ Node, browsers, Deno, Bun, edge✅ generated JS runs anywhere⚠️ Node; browsers need polyfills8⚠️ Node, browsers, Deno, Bun⚠️ Node ≥ 20.19; browser: @mongoosejs/browser9
Module formatCJS, ESM named importsCJSESM + CJSESM + CJSESMESMESM + CJSCJS + ESM (module field)ESM + CJSCJSESMESM + CJSCJSESMESM + CJSCJSESMESM + CJSCJSCJS (+ UMD dist)CJS
Works without eval (strict CSP, edge)✅ closures; JIT opt-in, falls back✅ never (validation)⚠️ object JIT by default; auto fallback10✅ never⚠️ JIT by default; auto jitless under CSP✅ never by default (JIT opt-in)✅ never✅ never✅ never✅ never✅ never✅ never✅ never❌ new AsyncFunction per schema✅ never⚠️ new Function; standalone precompile⚠️ Compile evals, falls back; Value never✅ code is generated at build time❌ needs new Function⚠️ reflection evals; static code not✅ 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⚠️ legacy TS decorators, or by hand11✅ plain JS; standalone optional✅ plain JS❌ requires ttsc transform (TypeScript 7)12✅ plain JS⚠️ optional pbjs codegen✅ plain JS
Defining schemas
How a schema is writtenplain object: '?string', { array: 'number' }plain object literal ('?string', kind key)builder / method chain (zod/mini: functional)functions + pipe()TS-syntax strings in object literals + chainingbuilder values: Schema.Struct({...}).pipe/.checkfunction 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 chainbuilder / method chainbuilder / method chain (mutable)decorators on classesJSON Schema or JTD documentbuilder (Type.Object…); TS-syntax strings via Type.ScriptTS types + transformer; type tagsAvro schema JSON (.avsc) or IDL.proto IDL or JSON descriptorMongoose Schema (SchemaType options)
The definition is data (store, send, rebuild)✅ plain objects (functions aside)✅ plain objects (functions aside)⚠️ via toJSONSchema / fromJSONSchema❌ 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⚠️ only via json.schema + @typia/utils✅ Avro JSON schema✅ JSON descriptors⚠️ via toJSONSchema() only
Optional vs nullable✅ '?x', 'key?', nullable: true⚠️ '?type' = absent/undefined/null✅ 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()⚠️ optional() also accepts null14⚠️ IsOptional() skips null and undefined✅ required vs type null / nullable✅ Optional, Union with Null, Undefined✅ TS ?:, | null, | undefined⚠️ ['null', T] union; absent needs default⚠️ presence only; null = unset⚠️ optional = absent/undefined/null
Unions, discriminated✅ union, discriminator❌ none✅ union, discriminatedUnion, xor✅ union, variant (discriminated)✅ 'a | b', .or(); auto-discriminated✅ Union, Literals, TaggedUnion⚠️ union(); no discriminated union✅ union(), tag detected automatically⚠️ Union(...); no discriminator⚠️ union([..]) / or(); no discriminator✅ union(), auto-discriminated❌ no union type✅ alternatives(); conditional() switch✅ union() with if(), unionOfTypes()⚠️ class-transformer discriminator✅ 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']⚠️ array shorthand, scalars only✅ z.tuple with rest✅ tuple, strictTuple, tupleWithRest✅ ['string', 'number'], variadic '...'✅ Tuple, TupleWithRest✅ tuple([..])⚠️ tuple([..]) strips extra items15✅ 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⚠️ ref: 'Model' for populate
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⚠️ Schema.declare; reviver ids for restore⚠️ define(name, fn); no registry⚠️ new t.Type(name, is, validate, encode); no registry⚠️ withBrand/withConstraint; no registry⚠️ withPredicate/map on a base type; no registry⚠️ unknown().assert()/chain(); no registry⚠️ addMethod(), mixed(check)✅ Joi.extend({ type })⚠️ createRule(), macro() methods✅ registerDecorator(), @ValidatorConstraint⚠️ addKeyword / addSchema, not types⚠️ Unsafe + Refine; Script/Module names✅ 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)⚠️ io-ts-types: UUID, ISO dates❌ 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)⚠️ BigInt, Map, Set; no Date✅ 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⚠️ BigInt; Date/Map/Set via io-ts-types⚠️ BigInt, Symbol; Date/Map/Set via InstanceOf⚠️ Date, BigInt⚠️ BigInt only⚠️ Date⚠️ Date, Buffer, symbol, function⚠️ Date (output), File⚠️ Date; Set/Map via each⚠️ Date via ajv-keywords or JTD⚠️ BigInt, Symbol, Undefined; no Date/Map/Set20✅ Date, bigint, Map, Set, typed arrays21⚠️ Buffer; Date, BigInt via custom types22⚠️ Uint8Array, Long; BigInt via fromObject23✅ 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 decorators per property✅ custom keywords (addKeyword)✅ Type.Refine on any type⚠️ TagBase validate expressions (strings)⚠️ only via a LogicalType❌ 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⚠️ experimental TaskDecoder, concurrent27❌ none❌ none28❌ none (non-goal)29✅ async test(), concurrent⚠️ external(): sequential, after sync rules30⚠️ async rules, in sequence✅ async constraints, concurrent31⚠️ $async schemas; awaited one by one❌ 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⚠️ first by default; errors: 'all'⚠️ first; all via failures()✅ all by default✅ all by default⚠️ first; collectErrors() per object✅ all by default⚠️ first by default; abortEarly: false⚠️ first by default; abortEarly: false✅ all fields; first per field✅ all by default; stopAtFirstError⚠️ first by default; allErrors: true33⚠️ Errors(): up to 8 by default✅ validate: all; assert: first⚠️ first; all via 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⚠️ path array, type/refinement; no params⚠️ context path, expected codec, value; no codes⚠️ code, expected, received; nested details⚠️ path array + message; no codes✅ code, path array, expected/keys/bounds⚠️ type, params; path is a string✅ type, path array, context⚠️ rule, meta; path is a dot string⚠️ constraint names; nested error tree⚠️ keyword, params; path as JSON Pointer⚠️ keyword, params; path as JSON Pointer⚠️ path string, expected, value; no code⚠️ errorHook(path[], value, type)❌ string with dotted path⚠️ kind, path string, properties
Localized messages✅ en, uk; a locale per call❌ none✅ 62 locales; per-call error fn✅ @valibot/i18n (34 locales); per-call message fn35⚠️ per-kind message config; no locales⚠️ message annotations, formatter hooks; no locales❌ none (per-call message string)❌ none❌ none❌ none (per-schema messages)❌ none⚠️ global setLocale(); English only✅ messages + errors.language per call✅ messagesProvider per call; English only⚠️ message functions; none built in✅ ajv-i18n: 23 locales✅ 38 locales; global Locale.Set❌ no messages❌ none❌ none⚠️ global mongoose.Error.messages
Unknown keys✅ reject (default), ignore, strip⚠️ reject only (no option)36✅ 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⚠️ ignore (t.type), strip (t.exact/t.strict); no reject✅ check ignores, parse strips, exact() rejects37✅ reject (default), strip✅ strict (default), strip, passthrough per call✅ keep default; exact() rejects, stripUnknown38✅ reject default; allowUnknown, stripUnknown⚠️ strip default; allowUnknownProperties()39✅ 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⚠️ check() returns a result43✅ 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 }⚠️ isValid(), ~standard.validate✅ 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⚠️ cycle-safe; no depth limit45❌ stack overflow, no depth limit⚠️ cycle-safe; no depth limit❌ stack overflow, no depth limit46❌ stack overflow❌ stack overflow⚠️ cycles accepted; deep input overflows⚠️ the overflow caught as an issue47❌ stack overflow❌ stack overflow✅ link.depth issue; maxRecursion(n)⚠️ finite schemas; the unknown-key copy overflows❌ stack overflow❌ RangeError; no detection or depth limit❌ RangeError; no detection or depth limit⚠️ WeakSet guard; deep input overflows48❌ 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⚠️ io-ts-types fromNullable/withFallback✅ 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⚠️ Value.Default; Decode pipeline52❌ 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)⚠️ ajv-keywords transform (strings only)✅ Type.Codec / Decode / Encode⚠️ built-in only: http, notations, llm✅ logical types (_fromValue)⚠️ conversion options only✅ 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)⚠️ io-ts-types NumberFromString etc.❌ 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)⚠️ http.query/headers, llm.coerce⚠️ coerceBuffers only✅ 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)⚠️ JTD compileSerializer (to JSON)✅ Codec + Value.Encode⚠️ protobuf.encode/decode, json.stringify✅ toBuffer/fromBuffer; logical _toValue✅ binary, toObject, ProtoJSON⚠️ getters, toJSON transform
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>⚠️ the class is the type⚠️ JTDDataType (JTD only)✅ Static<T>, also from plain JSON Schema✅ the TS type is the schema❌ none⚠️ via pbjs --dts codegen✅ 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⚠️ defaults only, in ~standard types❌ none✅ InferInput / Infer❌ none— no transforms✅ StaticDecode / StaticEncode— no transforms or defaults❌ none⚠️ $Properties vs class (pbts)⚠️ InferRawDocType vs hydrated
Generates TypeScript source✅ toTypeScript, saveTypes⚠️ model.dts, flat scalars only57⚠️ zod-to-ts (third-party)❌ none❌ .expression is ArkType syntax, not TS✅ SchemaRepresentation.toCodeDocument❌ none❌ none❌ none❌ none❌ none❌ none⚠️ joi-to-typescript (3rd party)❌ none— schemas are TS classes⚠️ json-schema-to-typescript (third-party)⚠️ json-schema-to-typescript (third-party)— the source is TypeScript⚠️ third-party: @ovotech/avro-ts✅ pbjs --dts / pbts (protobufjs-cli)⚠️ third-party: mongoose-tsgen
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⚠️ v1 on validators; path is a string60❌ 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⚠️ io-ts-to-json-schema (third-party)❌ none❌ none❌ none⚠️ @sodaru/yup-to-json-schema (3rd party)✅ 2020-12 via ~standard.jsonSchema✅ draft-07 via toJSONSchema()⚠️ class-validator-jsonschema (3rd party)✅ 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⚠️ json-schema-to-valibot (third-party, codegen)⚠️ @ark/json-schema 0.0.12 (no $ref)✅ SchemaRepresentation.fromJsonSchemaDocument❌ none❌ none❌ none❌ none❌ none⚠️ json-schema-to-yup (3rd party)⚠️ enjoi (3rd party)❌ none❌ none✅ draft-07, 2019-09, 2020-12, JTD✅ Compile/Value accept drafts 3–2020-12⚠️ @typia/utils OpenApiValidator (untyped)❌ none❌ none❌ none
LLM structured-output profile✅ the strict profile❌ none⚠️ openai/helpers/zod (third-party)⚠️ @ai-sdk/valibot (third-party)❌ 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⚠️ schema/Model variants + SqlModel❌ 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⚠️ embedded record only64❌ none❌ none❌ none❌ none❌ none❌ none❌ none❌ none❌ none❌ none❌ none❌ none❌ none❌ none❌ none❌ none❌ none (named types embed)❌ none (messages embed)⚠️ ref: id or populated doc65
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⚠️ schema.relations { to, type }66❌ none❌ none❌ none❌ none❌ none❌ none❌ none❌ none❌ none❌ none❌ none❌ none❌ none❌ none❌ none❌ none❌ none❌ none⚠️ ref, virtual populate (justOne)
Derive a schema: pick, omit, partial, extend✅ pick, omit, partial (deep), required, extend, keyof (./derive)⚠️ Projection kind (pick fields)✅ pick, omit, partial, required, extend, deepPartial✅ pick, omit, partial, required✅ pick, omit, partial, required, merge✅ mapFields + Struct.pick/omit/map✅ pick, omit, partial, assign⚠️ partial, exact, intersection; no pick/omit✅ 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)⚠️ class extends; @nestjs/mapped-types⚠️ allOf; ajv-merge-patch $merge/$patch✅ 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⚠️ scope(): resolves names, errors on missing❌ none❌ none❌ none❌ none❌ none❌ none❌ none❌ none❌ none❌ none⚠️ Ajv instance as $ref registry⚠️ Type.Module (named types)❌ none⚠️ protocols, shared registry⚠️ Root: lookup, resolveAll()⚠️ mongoose.model() registry
Evolution
Snapshot of a schema or model✅ model.toJSON(), schema.toSnapshot(), fromJSON⚠️ schema.toJSON(), fields only⚠️ via JSON Schema round trip❌ export only, no restore✅ .json → type.schema()✅ SchemaRepresentation toJson / fromJson❌ none❌ none❌ none❌ none❌ none⚠️ describe(), no restore✅ describe() / Joi.build()⚠️ toJSON(), no restore❌ none✅ schemas are JSON✅ JSON.stringify; Compile reads it back⚠️ export only (json.schema, reflect.schema)✅ 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⚠️ Value.Diff (generic JSON edits)❌ none❌ none❌ none❌ none
Compatibility of a change✅ backward / forward / full❌ none❌ none❌ none⚠️ via .extends() / .equals()❌ none❌ none❌ none❌ none❌ none❌ none❌ none❌ none❌ none❌ none❌ none⚠️ Type.Extends (assignability)❌ none✅ reader/writer resolution67❌ none❌ none
Field aliases (renames)✅ aliases, renamed by parse❌ none❌ none❌ none❌ none⚠️ Schema.encodeKeys (one encoded name)❌ none❌ none❌ none❌ none❌ none✅ object.from(old, new, alias)✅ rename(old, new, { alias })❌ none⚠️ @Expose({ name }) (class-transformer)❌ none❌ none❌ none✅ type and field aliases⚠️ renames free on the wire✅ 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)⚠️ deprecated: true passes through✅ JSDoc @deprecated → schema❌ none⚠️ [deprecated] kept in options❌ none
Tooling
Sample values from a schema✅ sample, seeded❌ none⚠️ zod-schema-faker (third-party)⚠️ valimock (third-party)✅ @ark/fast-check (arkToArbitrary)✅ Arbitrary.schema (own engine)❌ none⚠️ fast-check-io-ts (third-party)❌ none❌ none❌ none❌ none❌ none (example() is an annotation)❌ none❌ none⚠️ json-schema-faker (third-party)⚠️ Value.Create (zero values)✅ typia.random<T>() (randexp)✅ type.random()❌ none❌ none
A schema inferred from data✅ infer❌ none⚠️ json-to-zod (third-party, 2022)❌ 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()⚠️ fieldsAssign (struct fields only)✅ assign(), intersection()✅ intersection()✅ Intersect(...), and()✅ and() / intersection()⚠️ extend(other.shape); no intersection✅ concat()✅ concat()❌ none❌ none⚠️ allOf; ajv-merge-patch $merge✅ Intersect + Evaluate✅ TS intersection (A & B)⚠️ Type.forTypes (widening)❌ none✅ schema.add(other), new Schema([a, b])
Warnings about a definition✅ schema.warnings⚠️ model.warnings: refs, recursion❌ none❌ none❌ none❌ none❌ none❌ none❌ none❌ none❌ none❌ none❌ none❌ none❌ none✅ strict mode; strictTypes logs❌ none❌ compile errors only❌ none❌ none⚠️ reserved-key, duplicate-index warnings
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⚠️ Node stream codecs❌ 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⚠️ struct.type, struct.schema✅ _tag, props, types, name✅ tag, fields, alternatives, element⚠️ shape(); other parts private⚠️ name, shape, options; transforms opaque✅ 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) · ⚠️ partial, opt-in, with a caveat, or through a third-party package (named) · ❌ not built in · 🔜 planned by the maintainers, no code yet · — does not apply. Every cell was checked against the source of the published package in October 2026.

  1. No release since 2024-07; MetaMask publishes a maintained fork, @metamask/superstruct.
  2. No commits since the 2025-02 release.
  3. 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).
  4. 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.
  5. The browser bundle has no describe()/build() ('Manifest functionality disabled'), binary() or trace(); the main entry needs Node's url and util modules.
  6. The compiled validator needs code generation, so it does not run on Cloudflare Workers or under a CSP without unsafe-eval.
  7. Runtimes that forbid eval (Cloudflare Workers, strict CSP) can only run validation code pre-generated with ajv/dist/standalone at build time.
  8. Built for browserify-era Node polyfills; and since every type is created with new Function, eval-free edge runtimes (Cloudflare Workers) cannot load it.
  9. @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.
  10. 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.
  11. Standard (TC39) decorators are not supported; class-transformer's implicit conversion also needs emitDecoratorMetadata.
  12. 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.
  13. After registerSchema(), validating any decorated class throws the same TypeError (MetadataStorage.js:107).
  14. optional() lets null through and drops it from the output; nullable() keeps the key required; an absent key and undefined are the same.
  15. A longer array is accepted and truncated, not rejected.
  16. The invalid_length message is garbled in 0.5.5 ("expected an arraywith items", :852-866): a missing space and no count.
  17. Regex-only: uuid() accepts versions 1-5 only (a v7 UUID fails) and datetime() does not check ranges.
  18. The core has none: in strict mode a schema with an unknown format fails to compile ("unknown format "email" ignored in schema").
  19. An unregistered format name passes silently: Format.Test returns true when the name is unknown (_registry.ts:103-106; typebox-behaviour.mjs).
  20. Type.Date and Type.Uint8Array were removed in 1.0; the migration guide rebuilds them with Type.Unsafe + Type.Refine.
  21. An invalid Date (new Date("nope")) passes as a Date, and NaN passes as a number unless the numeric/finite transform flags are set.
  22. 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).
  23. verify() rejects a BigInt for int64 ("integer|Long expected") although fromObject and toObject accept it.
  24. A field validate runs even when the type check already failed (both messages reported).
  25. 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.
  26. A narrow that returns a Promise passes, because the predicate result is only tested for truthiness.
  27. TaskDecoder belongs to the experimental modules (lib/TaskDecoder.d.ts:2, "independent and backward-incompatible with stable ones").
  28. An async predicate silently passes every value (its promise is truthy).
  29. An async predicate silently passes every value.
  30. custom() given an async function passes and returns a Promise as the value (research-scripts/joi-extra.mjs).
  31. validateSync() skips async validators silently and reports the value valid (Validator.js:31; script).
  32. 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.
  33. The Ajv security guide says not to use allErrors: true in production (research/ajv-src/docs/security.md:41).
  34. An unknown nested key loses its path: { nested: { extra } } reports Field "extra" (research-scripts/metarhia-basics.mjs).
  35. 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.
  36. Keys named like Struct.prototype members (constructor, check, proto) pass silently: this[name] finds the inherited member (lib/struct.js:29-34).
  37. The same schema keeps unknown keys under check()/guard() and drops them under parse().
  38. noUnknown() strips unknown keys instead of rejecting them unless the schema is strict() (the README warns about it); exact() rejects.
  39. No way to reject unknown keys.
  40. whitelist deletes the keys from the validated object itself; forbidUnknownValues (default true) rejects an object with no metadata, so a plain object fails.
  41. equals accepts an extra key whose value is undefined.
  42. Each policy belongs to a different API: verify ignores, fromObject strips, ProtoJSON fromJson rejects by default.
  43. check(null) throws.
  44. 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.
  45. 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.
  46. The RangeError surfaces as a defect: the Result/Exit adapters throw "Result adapter can only return schema issues".
  47. The RangeError of a cyclic or 3 000-deep value is returned as an issue whose path is thousands of keys long.
  48. A cycle is accepted (coinductive), and there is no depth limit.
  49. Defaults apply only on the coercing calls (create, mask, validate with coerce: true), not in is()/assert().
  50. The default is a value, not a factory: default([]) hands every parse the same array (script).
  51. Defaults are written into the input object, and not inside anyOf/oneOf/not.
  52. 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.
  53. 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.
  54. Lenient: number().coerce() turns "1abc" into 1, number() accepts NaN, bigint() accepts true (script).
  55. number() also rejects values beyond Number.MAX_SAFE_INTEGER unless unsafe() (types/number.js:104).
  56. fromObject does not validate: { age: 'abc' } becomes 0 and { name: 42 } "42".
  57. 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.
  58. Schemas do not carry ~standard themselves ('~standard' in schema is false); wrap with Schema.toStandardSchemaV1 / toStandardJSONSchemaV1.
  59. ~standard.validate always returns a promise and runs with abortEarly: false; array indexes come back as strings.
  60. ~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.
  61. On models only (a Schema has none), and validate always returns a promise; it casts, so { n: '5' } is valid with value n: 5.
  62. Its JSDoc calls it semi-experimental.
  63. Marked @stability unstable.
  64. An id is never accepted, while the generated dts names the field companyId: string.
  65. A populated document is not validated with its own schema (an invalid Dog passes).
  66. The labels are reversed: a many field is labelled many-to-one and a single reference one-to-many.
  67. A resolver either builds or throws for one reader/writer pair; there is no backward/forward/full classification.
  68. Any non-integer number below 2^53 is inferred as float (32-bit), which loses precision when encoded (lib/types.js:247-252).
  69. Works on Effect Channels/Streams (unstable), not on Web Streams directly.
  70. Both are @stability unstable; the interpreter is the default.

Where each one fits ​

  • metaschema: one plain-object definition for validation, parsing, TypeScript types, JSON Schema and a domain model that evolves; any runtime, with or without eval.
  • Zod: the largest ecosystem and the default choice of many frameworks; codecs, JSON Schema both ways, a whole-schema compiler.
  • Valibot: the smallest bundles, built from functions a bundler can drop.
  • ArkType: definitions in TypeScript's own syntax, set-theoretic types and their relations, generated code.
  • Effect Schema: programs built on Effect; the broadest feature set of the TypeScript-first libraries, much of it marked unstable.
  • Yup, Joi, VineJS: form and request validation with casting and a wide rule set; Joi in hapi, VineJS in AdonisJS.
  • class-validator: DTO classes with decorators, as in NestJS.
  • Superstruct, io-ts, Runtypes, myzod, Valita: small composable validators; io-ts for fp-ts code, Runtypes for exact optional keys, Valita small with Standard Schema.
  • Ajv: a JSON Schema document is the contract, in every draft.
  • TypeBox: JSON Schema objects with static types and a fast compiled check.
  • typia: TypeScript types are the source of truth, compiled ahead of time.
  • avsc, protobuf.js: binary encoding and schema evolution shared with other languages.
  • Mongoose: an ODM around MongoDB: queries, populate, middleware and change tracking around its schemas. metaschema's ./mongodb covers the schema side (BSON types, _id and references, indexes, the validator, checked updates) with your own driver.
  • Metarhia metaschema: the original, inside the Metarhia stack; see Migrating from metarhia.

Released under the MIT License.