github colinhacks/zod v4.6.0

latest release: v4.6.1
4 hours ago

Zod 4.6 is now available.

npm install zod@latest

At a glance:

  • .validate() — checks input validity without building a result (up to 35x faster than .safeParse().success on a compiled schema)
  • z.instanceof().properties() — validates properties of an instance
  • fromJSONSchema() — enforces six validation keywords it used to ignore
  • z.iban() — electronic-format IBAN plus mod-97 checksum
  • z.withParser() — installs a parser generated elsewhere, for environments without new Function
  • Faster CommonJS — drops the getter on every export (~3x faster z.validate() under require)
  • Memory retention in recursive schemas — releases the parsed input, fixing a 4.5 out-of-memory regression
  • @zod/mini — Zod Mini as a standalone package, versioned in lockstep with zod since 4.5

.validate()

Standalone boolean validation, in Zod, Zod Mini, and Zod Core. It answers "is this input valid?" without constructing a ZodError, which makes rejection cheap. The return type is a guard on the schema's input type.

z.validate(z.string(), "hi"); // true
z.validate(z.string(), 42);   // false

It is a method on Zod Classic schemas too. (#6547)

const Player = z.object({
  username: z.string(),
  xp: z.number(),
});

if (Player.validate(data)) {
  data.username; // narrowed
}

In conjunction with z.compile(), this can be up to 35x faster than .safeParse().success on invalid input.

Time per call on invalid input, compiled with z.compile() — lower is better (benchmark)

Uncompiled schemas

Without compilation it is up to 5.9x faster. The saving is the result object: .safeParse() allocates one with an accessor pair on every call, and .validate() allocates nothing.

Time per call on invalid input, plain schemas — lower is better (benchmark)

Both charts measure the failure path. The key feature of .validate() is that it can short-circuit on the first issue it encounters, instead of aggregating a full ZodIssue[] array.

Note

Async refinements are covered by .validateAsync().

z.properties()

A new API for validating specific properties of an object. Unlike z.object() it validates in-place, so it plays nice with class instances. (#6536)

const responseLike = z.properties({ status: z.number().min(200).max(299) });

responseLike.parse(new Response("ok", { status: 200 }));  // ✅ a real Response
responseLike.parse({ status: 204 });                      // ✅ a plain object

A corresponding .properties() method has been added to ZodInstanceOf.

Zod

const okResponse = z.instanceof(Response).properties({
  ok: z.literal(true),
  status: z.number().min(200).max(299),
});

Zod Mini

const okResponse = z.instanceof(Response).check(...z.properties({
  ok: z.literal(true),
  status: z.number().check(z.minimum(200), z.maximum(299)),
}));

The input comes back untouched, so the prototype survives and the methods still work. That is the part z.object() cannot do: it would hand back a plain object and the Response would be gone.

const res = await fetch("/api/user");

okResponse.parse(res) === res; // ✅ true

fromJSONSchema()

Six additional JSON Schema keywords are now supported in z.fromJSONSchema(). (#6535)

const schema = z.fromJSONSchema({
  type: "object",
  minProperties: 2,      // also maxProperties
});

schema.parse({ a: 1 });        // ❌ too few properties
schema.parse({ a: 1, b: 2 });  // ✅

Both property bounds count the input's own keys. Array uniqueness is structural, so [{ a: 1 }, { a: 1 }] is a duplicate.

z.fromJSONSchema({ type: "array", uniqueItems: true }).parse([{ a: 1 }, { a: 1 }]); // ❌

z.fromJSONSchema({
  type: "array",
  contains: { type: "number" },   // also minContains and maxContains
  minContains: 2,
}).parse(["a", 2]);               // ❌ only one number

z.iban()

A new string format: an IBAN in electronic format, with a valid ISO 7064 MOD 97-10 checksum. (#6571)

z.iban().parse("DE89370400440532013000"); // ✅
z.iban().parse("DE89370400440532013001"); // ❌ bad checksum

z.withParser()

z.compile() builds its parser with new Function, which a strict Content Security Policy blocks. z.withParser() is that installer on its own: it takes a parser generated somewhere else, at build time or by a native compiler, and installs it under the same contract. (#6575)

const Player = z.object({ username: z.string(), xp: z.number() });

// isPlayer is a type guard your build step generated
const Fast = z.withParser(Player, (input) =>
  isPlayer(input) ? { username: input.username, xp: input.xp } : z.INVALID
);

The supplied parser owns the whole result, so it has to return what the schema would have returned. This one rebuilds the object rather than handing back its input, because z.object() strips unknown keys. Returning z.INVALID hands the input to the runtime, which stays the only source of ZodErrors.

Faster CommonJS

TypeScript compiles a re-export to a getter, and 252 of the 255 exports on Zod 4.5's CommonJS entrypoint were getters. V8 could not see a constant callee behind one, so it could not inline the call. The 4.6 build emits plain properties and freezes the exports object. On a compiled schema, z.validate() under require is about 3x faster than it was in Zod 4.5. (#6564)

const { z } = require("zod");
const CompiledPlayer = z.compile(Player);

z.validate(CompiledPlayer, data); // ~3x faster than Zod 4.5

Only calls through the namespace were affected. A method call like Player.safeParse(data) never reads the exports object, and the ESM build is unchanged.

Memory retention in recursive schemas

A recursive schema held the input and output of its last parse until the next parse replaced it, so one long-lived schema pinned every object it had touched. Zod 4.4 released that input and Zod 4.5 did not, which surfaced as an out-of-memory failure on a repository-wide lint run. The parse state is weak throughout now: one parse of a 29k-node tree retains 2.2 MB where it used to retain 10.1 MB, and recursive parses give up about 6% for it. (#6572)

const Category = z.object({
  name: z.string(),
  get children() {
    return z.array(Category);
  },
});

Bug fixes

⚠️ Error maps run on the first read of error

Because safeParse() now builds its error lazily, error maps — global, locale, and per-schema error — run when result.error is first read, not at parse time. Code that swaps z.config() between the parse and the read gets the newer configuration. (#6519)

const result = schema.safeParse(12);
z.config(z.locales.fr());
result.error.issues[0].message; // French in 4.6, English in 4.5

An error map with a side effect never runs if nothing reads the error. Throwing parses are unaffected — .parse() builds and throws its error immediately, never takes the lazy path, and its stack still points at your call site.

⚠️ z.emoji() rejects component-only strings

Unicode's Emoji_Component property covers the pieces that attach to an emoji, so z.emoji() accepted "123", "#", "*", and a lone zero-width joiner, variation selector, or skin tone modifier. The pattern now requires at least one pictograph, regional indicator, or keycap. (#6532)

z.emoji().parse("😀");   // ✅
z.emoji().parse("1️⃣");   // ✅ the keycap is the anchor
z.emoji().parse("123");  // ❌ was accepted in 4.5

Flags, subdivision flags, skin-tone-modified emoji, and ZWJ sequences are unchanged. Closes #6515.

⚠️ Numeric enum options no longer include the reverse mappings

A numeric TypeScript enum also carries its reverse mapping (0 to "UK") at runtime. The parser already ignored those keys, but .options was read straight off the enum object, so a three-member enum listed six values and three of them failed to parse. (#6542)

enum Country { UK, Germany, France }

z.enum(Country).options; // 4.5: ["UK", "Germany", "France", 0, 1, 2] — 4.6: [0, 1, 2]

⚠️ base64 patterns

The runtime patterns for z.base64() and z.base64url() are the character sets, with length and padding enforced in code, so a multi-megabyte string can no longer overflow the regex stack through a composed schema. The JSON Schema output still emits the exact block forms, so z.toJSONSchema() is unchanged. (#6534, #6527)

Composing z.base64() into a template literal now checks the alphabet but not the length, which is how z.creditCard() already behaves there. The exported z.regexes.base64url is now the length-aware form, so it overflows on a multi-megabyte input the same way z.regexes.base64 does.

⚠️ The email pattern dropped its lookaheads

z.email() opened with two lookaheads, and the second scanned the whole string before the match began. Both are gone, and the rule they enforced — no empty segment in the local part — is expressed structurally instead, so z.email() accepts and rejects exactly what it did before. Valid addresses validate roughly twice as fast. (#6573)

The pattern string is user-visible, and every copy of it changes: z.regexes.email, which has no capture groups now — neither of the two it used to expose held a usable value; issue.pattern on a failed z.email(); and the pattern that z.toJSONSchema() emits, which no longer carries a lookahead, so validators outside ECMAScript can compile it.

Composing an email into a template literal also stops applying its no-consecutive-dots rule to the rest of the string.

z.templateLiteral([z.email(), "|", z.string()]).parse("a@b.cc|a..b");
// 4.5: ❌ — the lookahead reached past the email segment — 4.6: ✅

⚠️ Chained checks no longer overwrite each other in JSON Schema

Each check used to write its own bounds into the schema as it attached, in chain order, so a format check applied after .min() and .max() replaced the tighter values with its own range. The converter folds the checks as a conjunction now. The order they are chained in no longer changes the output. (#6554, #6553)

z.toJSONSchema(z.number().min(0).max(23).int());
// 4.5: { minimum: -9007199254740991, maximum: 9007199254740991 }
// 4.6: { minimum: 0, maximum: 23 }

Runtime parsing enforced the bounds in every version. Only the emitted schema was wrong. The same fold fixes two more cases: a repeated multipleOf kept the first divisor and dropped the rest, so z.number().multipleOf(2).multipleOf(3) emitted a schema that accepts 4, and z.string().min(8).length(5) emitted minLength: 5, widening a bound the runtime still rejected. Closes #6550.

⚠️ Metadata members materialize on first read

Eight members on a Zod Classic schema — .format, .minLength, .maxLength, .minValue, .maxValue, .isInt, .minDate and .maxDate — are computed from the checks now instead of being written onto every instance at construction. Each one is a prototype getter that becomes an own property on first read. (#6554)

const s = z.string().min(3).max(9);

Object.keys(s); // 4.5: ["def", "type", "format", "minLength", "maxLength"] — 4.6: ["def", "type"]
s.minLength;    // 3 in both
Object.keys(s); // 4.6: ["def", "type", "minLength"]

A key is absent until something reads it, and Object.assign({}, schema) copies only the members that have been read. Deleting one restores the getter, and the next read recomputes it.

The values can move too, because the getters read the same fold the JSON Schema converter does. An order-dependent chain reports the tighter bound now instead of whichever check wrote last.

z.string().min(8).length(5).minLength; // 4.5: 5 — 4.6: 8

Commits

Zod 4.6 rolls up 72 commits.

Don't miss a new zod release

NewReleases is sending notifications on new releases.