Migrating from 2.x
3.0 moves two things. The generators of TypeScript and JSON Schema leave the core entry for the @alexify/metaschema/generators subpath, which takes model.dts, schema.toInterface() and saveTypes with them, and schema['~standard'].validate is parse rather than check. The schema language, check, the shape of a result and the typings of 2.0 stay as they are, with the smaller changes below. This page goes through them with the 2.0 behaviour first and the 3.0 behaviour after it; the condensed list is under "Upgrading from 2.0" in the CHANGELOG.
Everything not listed here is additive: parse, serialize, checkAsync and parseAsync, the JSON Schema export with its four targets and the strict profile, the JIT backend, JSDoc and named types in the generated TypeScript, InferOutput and the maxDepth option work on a 2.0 schema as it is. A project coming from 1.x goes through Migrating from 1.x first.
The generators subpath
model.dts, toInterface() and saveTypes
The TypeScript rendering is no longer in @alexify/metaschema: model.dts is undefined, schema.toInterface is not a function and the core entry has no saveTypes export. The @alexify/metaschema/generators subpath has them as functions that take the schema or the model:
const { Model } = require('@alexify/metaschema');
const model = new Model({}, new Map([
['Company', { Registry: {}, name: { type: 'string', unique: true } }],
['Person', { Entity: {}, name: 'string', employer: 'Company' }],
]));
// 2.0
const { saveTypes } = require('@alexify/metaschema');
model.dts; // 'interface Company {\n name: string;\n companyId?: string;\n}\n\ninterface Person { ...'
model.entities.get('Person').toInterface(); // 'interface Person {\n name: string;\n employerId: string;\n personId?: string;\n}'
await saveTypes('./types/model.d.ts', model);
// 3.0
const { toTypeScript, saveTypes } = require('@alexify/metaschema/generators');
model.dts; // undefined
model.entities.get('Person').toInterface(); // TypeError: model.entities.get(...).toInterface is not a function
toTypeScript(model); // what model.dts returned
toTypeScript(model.entities.get('Person')); // what toInterface() returned
await saveTypes('./types/model.d.ts', model);The text is the same; toTypeScript(model) renders what model.dts did:
interface Company {
name: string;
companyId?: string;
}
interface Person {
name: string;
employerId: string;
personId?: string;
}A program that prefers the methods calls register() of the subpath once at startup. It installs model.dts, model.toTypeScript(options), schema.toInterface(options), schema.toJSONSchema(options) and model.toJSONSchema(options) on the classes, and the jsonSchema converter of Standard JSON Schema on schema['~standard'], for every schema, the ones built before the call too:
require('@alexify/metaschema/generators').register();
model.dts === toTypeScript(model); // trueNothing happens on import, and register() is idempotent. The subpath is 7.7 KB min+gzip on top of the core entry, which is 21.3 KB in 3.4 (18.6 KB in 3.3, 17.7 KB in 3.2, 4.5 KB and 16.1 KB in 3.0) and carries no Node.js runtime any more; the browser entry is the same. toJSONSchema is new (2.0 had no JSON Schema export), and so are the JSDoc and the named option of the rendering. See The generators subpath and Generating interfaces from a model.
Validation
~standard.validate is parse
A consumer of Standard Schema (tRPC, TanStack Form, Hono, …) gets the value parse gives: the default of a missing key filled in and the parse function of every field applied. In 2.0 validate was check, so a default was an annotation and a parse key an option the lint reported:
const event = Schema.from({
name: 'string',
'level?': { type: 'integer', default: 1 },
when: { type: 'string', parse: (text) => new Date(text) },
});
event['~standard'].validate({ name: 'deploy', when: '2026-10-10T12:00:00.000Z' });
// 2.0: { value: { name: 'deploy', when: '2026-10-10T12:00:00.000Z' } }
// (and event.warnings: 'Warning [unknown-option]: option "parse" of "when" is not known to type "string"')
// 3.0: { value: { name: 'deploy', when: 2026-10-10T12:00:00.000Z, level: 1 } }A schema without defaults and decoders returns the value it was given, the same reference, as 2.0 did. Keys the schema does not have follow its own unknown metadata in both versions, and libraryOptions are the options of parse now, unknown: 'strip' included (2.0 threw for it):
const user = Schema.from({ name: 'string' });
const value = { name: 'Marcus' };
user['~standard'].validate(value).value === value; // 2.0 and 3.0: true
user['~standard'].validate({ name: 'Marcus', extra: 1 }, { libraryOptions: { unknown: 'strip' } });
// 2.0: TypeError: unknown must be "reject" or "ignore", got "strip"
// 3.0: { value: { name: 'Marcus' } }A schema with an async function among its validators, decoders and custom checkTypes (in it or in an entity it references) returns a promise of the result, which the specification allows and its consumers await. 2.0 reported the promise itself:
const signup = Schema.from({
login: { type: 'string', validate: async (login) => login !== 'taken' || 'is taken' },
});
signup['~standard'].validate({ login: 'marcus' });
// 2.0: { issues: [ { code: 'custom', path: ['login'], message: '[object Promise]', params: {} } ] }
// 3.0: Promise { { value: { login: 'marcus' } } }The typings say validate returns StandardResult<T> | Promise<StandardResult<T>>, with types.input as Infer<D> and types.output as InferOutput<D>, so a consumer typed on the output of the schema sees the decoded value. The JSON Schema converter, ~standard.jsonSchema, is new and comes with register() of the generators. See check, parse and ~standard side by side.
A struct rejects an array
2.0 accepted [] for a struct whose fields are all optional (typeof [] === 'object', and no key to look at) and reported the missing keys of any other struct. An array is not of expected type: object now, whatever the fields:
const order = Schema.from({ address: { 'city?': 'string', 'zip?': 'string' } });
order.check({ address: [] }).errors;
// 2.0: []
// 3.0: [ 'Field "address" not of expected type: object' ]A promise from a validator throws
A validate function, a custom type's checkType or a field's parse function may return a promise to checkAsync and parseAsync, which settle them after the walk, in field order. The synchronous check and parse throw a TypeError with the code ERR_ASYNC_VALIDATOR for one; 2.0 stringified it into a custom issue:
const signup = Schema.from({
login: { type: 'string', validate: async (login) => login !== 'taken' || 'is taken' },
});
signup.check({ login: 'taken' }).errors;
// 2.0: [ 'Field "login" [object Promise]' ]
// 3.0: TypeError: The validator of "login" returned a promise; use checkAsync or parseAsync
// (code: 'ERR_ASYNC_VALIDATOR')
(await signup.checkAsync({ login: 'taken' })).errors;
// 3.0: [ 'Field "login" is taken' ]A validator that returned a promise in 2.0 never validated anything, so the change surfaces a bug rather than creating one. ~standard.validate returns a promise only for a schema with an async function (above); a plain function that returns a promise throws there too. See Async.
A graph deeper than 128 embedded records is invalid
check, parse and serialize({ check: true }) count the embedded records they are inside and record a depth issue at the first record past maxDepth, 128 by default, instead of walking until the stack runs out:
const node = new Model({}, new Map([['Node', { Struct: {}, 'next?': 'Node' }]])).entities.get('Node');
let value = {};
for (let i = 0; i < 200; i++) value = { next: value };
node.check(value).valid;
// 2.0: true
// 3.0: false
node.check(value).issues[0];
// 3.0: { code: 'depth', path: ['next', 'next', ...], message: 'is nested deeper than 128 references', params: { max: 128 } }
// (129 keys in the path)
node.check(value, { maxDepth: 256 }).valid;
// 3.0: trueA program that validates deeper graphs passes a larger maxDepth on the call. A reference to a stored kind is an id and never recurses, so the limit concerns memory kinds and embed: true only. See Validation.
The validate, parse and serialize of a nested struct are not keys of the value
A nested struct's validate function, and its parse and serialize since 3.0, are the struct's own, as they are at the root. 2.0 let a value carry a key of that name:
const order = Schema.from({
address: { city: 'string', validate: (address) => address.city !== '' || 'city is empty' },
});
order.check({ address: { city: 'Kyiv', validate: 1 } }).errors;
// 2.0: []
// 3.0: [ 'Field "address" has unexpected keys: validate' ]Under unknown: 'ignore' the key is accepted like any other, and parse with unknown: 'strip' drops it.
maxErrors is a whole number
maxErrors must be an integer of at least 1, or Infinity; 2.0 took 1.5 and stopped after two issues:
const schema = Schema.from({ a: 'string', b: 'string', c: 'string' });
schema.check({}, { maxErrors: 1.5 }).errors;
// 2.0: [ 'Field "a" is required', 'Field "b" is required' ]
// 3.0: TypeError: maxErrors must be an integer of at least 1, got 1.5The schema language
Definitions that could never work are errors
Three definitions 2.0 built, and failed on later, throw SchemaDefinitionError when the schema is built, like every other broken definition:
Schema.from({ flags: { object: { boolean: 'string' } } });
// 2.0: builds; every value fails with 'Field "flags" keys must be of type boolean'
// 3.0: SchemaDefinitionError [ERR_INVALID_DEFINITION]: Type "object" needs a key type of "string" or "number" in "flags"
Schema.from({ flags: { map: { boolean: 'string' } } });
// 2.0: builds, and accepts a Map with keys of any type
// 3.0: SchemaDefinitionError [ERR_INVALID_DEFINITION]: Type "map" needs a key type of "string" or "number" in "flags"
Schema.from({
shape: {
union: [
{ kind: { enum: ['circle'], default: 'circle' }, r: 'number' },
{ kind: { enum: ['square'] }, side: 'number' },
],
discriminator: 'kind',
},
});
// 2.0: builds; { shape: { r: 1 } } fails with 'Field "shape.kind" is not one of: circle, square'
// 3.0: SchemaDefinitionError [ERR_INVALID_UNION]: Union discriminator "kind" of branch 0 cannot have a default in "shape"The key type of an object or a map is 'string' or 'number'. A default on the discriminator field can never apply, because the branch is picked before the defaults of the branch are filled in; see Discriminated unions.
TypeScript
Infer leaves out a key with a default
A field with a default is an optional property on the input side, because parse fills it in; InferOutput<D>, new in 3.0, has it present:
import { Schema, type Infer, type InferOutput } from '@alexify/metaschema';
const event = Schema.from({ name: 'string', status: { type: 'string', default: 'draft' } });
type EventInput = Infer<typeof event>;
// 2.0: { name: string; status: string }
// 3.0: { name: string; status?: string | undefined }
type Event = InferOutput<typeof event>;
// 3.0: { name: string; status: string }
const input: EventInput = { name: 'deploy' };
// 2.0: error TS2741: Property 'status' is missing in type '{ name: string; }'
// 3.0: compilesWhere the key was declared optional ('level?': { type: 'integer', default: 1 }), null stays in its input type (level?: number | null | undefined) and the output type is level: number | null. The generated interfaces are unchanged (every field present), so a value typed from the dts is still assignable to Infer, and the output type to the dts. A Schema<D> is a StandardSchemaV1<Infer<D>, InferOutput<D>> now, where 2.0 had Infer<D> on both sides. See The output of parse.
CheckContext has more slots
For code that builds on the internals and calls field.check(value, context, key) with a context of its own: the context of a check has five new slots, depth and maxDepth (how many embedded records the check is inside, and how many it may be), source (the value the check started from, when the schema can reach it again through a reference), async (the validators that returned a promise, null in a synchronous check) and scratch (the context parse tries the branches of a plain union in, null in a check). The CheckContext type declares them; the keys of the object, in their order:
// 2.0: issues, count, limit, path, seen, unknown, references, root, messages
// 3.0: issues, count, limit, depth, maxDepth, path, seen, source, unknown, references, root, messages, async, scratch
// 3.1: the same, then renamedA context built by hand needs depth: 0, maxDepth: 128, source: null, async: null, scratch: null and, since 3.1, renamed: false (whether the check read a field from an alias) besides the slots of 2.0, and its unknown may be 'strip' in a parse. The internal constructor of an exception issue takes the key of the field as well. Nothing changes for a validator, a checkType or a decoder: they receive the value and the path, as before.
The JIT backend
compile() returns a report
@alexify/metaschema/compile is new in 3.0, so there is nothing to change, only what to expect of it: compile(target) replaces the check of a schema, or of every entity of a model, in place and returns { accelerated, error } rather than the schema; register(options) compiles every schema built from then on, not the ones built before; and the default mode keeps the closures silently where new Function is not allowed:
const { compile, register, canEval } = require('@alexify/metaschema/compile');
compile(user); // { accelerated: true }: user.check runs generated code now
compile(user, { jit: false }); // { accelerated: false }: the closures are back
compile(model); // { accelerated: true }: every entity of the modelerror is set when a check could not be generated in the default mode (a SchemaDefinitionError with the code ERR_JIT_BUILD, the generator's error as its cause; the closures stay), register({ onFallback }) is told of every such schema, and jit: true throws instead (ERR_NO_EVAL where there is no eval at all). A compiled schema records exactly the issues the closures record. See The JIT backend.
Since 3.0: 3.1 and 3.2
The releases after 3.0 add without breaking, but a few behaviours a 2.x program meets on the way changed:
- 3.1: two fields of one key are an error.
{ a: 'string', 'a?': 'number' }, or a calculated field and a field of one name, isERR_INVALID_DEFINITION; the second silently replaced the first before. - 3.1:
aliasesis read. It is a key of a field (old names the field is read from), soaliases: 'old'(not a list) isERR_INVALID_DEFINITION, and a custom type no longer sees an option of that name. See Model evolution. - 3.1:
JSON.stringify(model)writes the snapshot rather than the instance's own enumerable properties. - 3.1: a subpath function given the wrong thing throws
ERR_INVALID_TARGET(a plainTypeErrorwith a code, likeERR_ASYNC_VALIDATOR). - 3.2: the order of the issues of an async check. Independent async validators are in flight together: a synchronous validator met after an unrelated promise runs during the walk, so the issues of the walk come first and the settled ones after them, in field order.
- 3.2: a
patternis never run over a string longer thanlength.max; such a string has itslengthissue alone. - 3.2: the message of
unexpectedlists the first ten keys and says how many more;params.keyskeeps them all.
Checklist
- Replace
model.dts,schema.toInterface()andsaveTypesof the core entry withtoTypeScript(target, options)andsaveTypes(file, target, options)of@alexify/metaschema/generators, or call itsregister()once at startup. - Where a schema goes to a Standard Schema consumer, expect the value
parsegives: defaults filled in,parsefunctions applied, and a promise for a schema with anasync function. A 2.0 schema that carried aparsekey for its own purposes has a decoder now. - Replace
checkandparsewithcheckAsyncandparseAsyncwhere a validator, acheckTypeor a decoder returns a promise (2.0 reported[object Promise]for it). - Expect
not of expected type: objectfor an array where a struct is expected, oneunexpectedissue for avalidate,parseorserializekey inside a nested struct of the value, and adepthissue past 128 embedded records (maxDepthon the call for deeper graphs). - Fix the definitions that throw at build time: the key type of an
objectormap('string'or'number'), adefaulton a discriminator field, and a fractionalmaxErrorson a call. - Read
Infer<D>as the input side (a defaulted key optional) andInferOutput<D>as the output ofparse; code that builds aCheckContextby hand adds the new slots. - Then, as wanted:
compile()orregister()of@alexify/metaschema/compilefor the JIT,toJSONSchemafor the JSON Schema export,{ named: true }for named types.
The pages for what is new: Parse and serialize, The JIT backend, JSON Schema, Standard Schema and TypeScript.