Last 12 weeks · 66 commits
4 of 6 standards met
Parses input that contains reference cycles. Today this is a — included, so it isn't even catchable through the result. Fixes #5346. doesn't get this, and pays 25 gzipped bytes for not getting it. Shape Core gains one seam: A container hands the memoizer the object it is about to build into. That is core's entire share of the mechanism: the interface, one field, and one changed line per container — is read once, at construction, into a closure constant, so a schema that was never given one runs exactly the code it runs today. In the JIT the same constant decides which of two strings gets emitted, so non-injected schemas produce byte-identical generated code. The memoizer itself is . Nothing outside that module references it unless a layer asks for it, so it shakes out completely: links none of it and the string doesn't appear in its bundle. Classic opts a container in with one line before its : The one other core line is in , so a repeat visit doesn't re-run the node's checks against a value that is still being built. What the memoizer does It registers the real output object before any child is parsed, so a reference back to the same input resolves to the object the caller will actually receive — nothing is copied afterwards. The lookup is keyed on , not on the input alone, and that is what makes mutual recursion sound: one node validated against two schemas needs an output per schema. A repeat visit returns without ever reaching core, which is also what stops the node's checks running a second time against a half-built value. A cycle that closes through a throws — the transform's output can't exist at the moment the back-edge needs to bind, so the alternatives are a clear error or a silently wrong graph. Transforms that aren't on the cycle are unaffected. A schema that can't re-enter itself disarms both halves on its first parse: the wrapper puts the original back and becomes a single field read. Cost Bundle, esbuild / : Runtime, best-of-3 processes per side, both pinned to the same base: Non-recursive parsing is unchanged — those rows are run-to-run noise. The last row is the price: a recursive schema over ordinary acyclic data pays ~2.7×, for the memo lookup and insert on every node. That is not a consequence of skipping the JIT — the fastpass is still generated and used, and the jitless path carries the same absolute per-node overhead, which it wouldn't if the JIT were being bypassed. Putting the same logic inside core's containers instead measured ~2.6×, so this shape costs essentially nothing over that while keeping at 25 bytes. Behavior change Under a recursive schema, two positions holding the same input object now produce the same output object: The output graph mirrors the input graph, which is also what arktype does. Non-recursive schemas are untouched and still copy. The alternative — scoping entries to the parse stack so acyclic input is bit-for-bit unchanged — diverges between sync and async. Under sibling subtrees are in flight together, so the second occurrence of a shared node finds the first one's in-progress entry and binds to it, while walks siblings sequentially and doesn't. "Currently being parsed" only equals "is my ancestor" when parsing is sequential. Keeping entries for the whole parse makes the two agree. What does not change: memoization skips the container's parse, not the checks and transforms layered on top of it. A or on a shared node still runs once per reference, exactly as it does on a non-recursive schema, so side effects keep their existing counts. Limits Measured, and worth knowing before merging: Maximum recursion depth drops from ~3400 to ~1100 levels. The interceptor adds a stack frame per container, so a deeply nested chain that parses today can overflow. Binary-searched, with (which has no memoizer) as the control. on a cycle produces a truncated graph. builds a fresh object after both sides are parsed, so it can never be the object a back-edge already resolved to. Values validate correctly and invalid input is still rejected, but the cycle is replaced by a copy. Same class as transform-on-a-cycle, which throws; this one doesn't yet. Identity is keyed on , not on input alone. That is what keeps mutual recursion sound, but it means gives a root that is not the node its own cycle points back to — the root is keyed on the partial clone, the cycled node on the original. on a cycled node doesn't freeze it.** Freezing a node that is still being built would make its remaining keys fail to assign, silently. Supersedes #5347 and #5664.
In Zod v4, the global configuration method z.config allows customizing error messages via the customError and localeError function, which currently only receives the issue parameter. The requirement is for customError localeError to support a second parameter, which provides the actual schema instance responsible for the validation error. This enables direct access to the meta information (such as title, label, etc.) attached to that schema instance and allows developers to implement consistent localized error handling logic. Key Points: customError(issue, schemaInstance): the second parameter is the schema instance where the error occurred, localeError accepts the same parameters. Works for both base and chained schemas (e.g., min/max/refine), always providing the schema with its associated meta. Compatible with localization and meta-driven error formatting, enabling easier access to meta fields like title or label for custom error messages. Expected Benefits: Simplifies multilingual and meta-driven error handling. Resolves the problem of losing meta information in chained validations. Makes Zod’s error handling API more consistent and easier to use for localization and customization scenarios. Using is not safe, as it may not necessarily point to .
Adds and to , mirroring the methods already has. Both take a set of discriminator values and return a narrower discriminated union. This is the variant of the long-running "exclude" request that TypeScript can actually express — the ask in #829 is a tuple filter, not the arbitrary schema negation from #2862 that has no type-level representation. The inferred output type is exactly the surviving options, so it stays accurate. Three construction-time guards, since none of these cases can be represented honestly in the result type: An unknown discriminator value throws, like the guard on . An option whose discriminator is a accepts several values. Selecting only some of them would silently drop the rest, so that throws. A union carrying refinements throws, matching and . The accepted values come from the input side of each option's discriminator, which is what is built from — so a codec discriminator is selected by its encoded value. For every other discriminator the two sides coincide. Closes #829
I tried implementing a codec for transforming a record into a map, where S is a subtype of string, K is arbitrary, and T may be either a codec or a type. This would be useful to define arbitrary KV containers decoded from json inputs. What I could get so far: There seems to be no plausible way to do it currently.
Closes #2854. Closes #5224. Background was deprecated in v3.21.0 and removed in v4. The reason from #2106: The API has this same problem [as ]. I'm deprecating it for the same reason. That problem: a recursive switch over schema types that 1. cannot see user-defined schema subclasses, 2. accumulates edge cases for every new schema type, and 3. silently breaks on advanced shapes (transforms, branded, discriminated unions, lazy/recursive, etc.). Issue #2854 has been the symptom: 60+ commenters asking what to use instead. Community libraries exist (, ) but the most popular one drops about half the advanced types and stack-overflows on recursive schemas. Approach This PR adds a v4-native implementation that addresses the design concerns of #2106 head-on rather than reproducing the original brittle pattern. Dispatches on , not . Custom schemas with an unknown fall through to identity instead of being silently mishandled. The visitor enforces exhaustiveness on the known set, so any missed case is a compile error. Cycle-safe via a cache. Lazy schemas unfold lazily, so recursive shapes terminate; shared sub-schemas are visited once. Non- cycles — v4's getter-based recursive objects — are broken with a placeholder that resolves through the cache at parse time. Bottom-up rewrite as the primitive. One internal traversal in backs all three public helpers; is a handful of lines on top of it. , , etc. become handlers rather than new switches. returns a structural type. The inferred return is a structural whose properties are the original properties wrapped in — rather than a generic . Keeps , , , etc. usable on the result. / runtime projections. Companion helpers that descend through pipes to the input or output side of the composition, sharing the same visitor infrastructure. Covers the full v4 def vocabulary: object, array, tuple (+rest), record, map, set, union (incl. discriminated union), intersection, optional, nullable, default, prefault, nonoptional, catch, readonly, promise, success, pipe, function, lazy. Leaves (primitives, enum, literal, transform, custom, file, etc.) returned untouched. The traversal lives in so both and reuse it, but it is not exported from any public barrel; each variant has its own thin wired to its own . Discriminated union note Making the discriminator field optional collapses the fast-path lookup (every option ends up with as a possible discriminator value). To keep parsing correct, degrades a discriminated union into a plain over the already-partialed options. Validation semantics are preserved (try-each), the only loss is the discriminator fast-path. This is documented in a test. Attribution The traversal pattern (bottom-up rewrite + sentinel) is adapted from @jaens's v3 gist (Apache-2.0). The v4 implementation here is rewritten: dispatch instead of , schema construction via instead of , and full v4 def coverage. Attribution noted at the top of the visitor file. Example Why the traversal is internal The brittle thing #2106 identifies is each derived helper growing its own switch. A single dispatch primitive fixes that whether or not it is exported: , , and are each a handful of lines over one shared traversal, and a future / is a handler rather than another switch. It is deliberately not exported, because the traversal's contract is not settled enough to freeze as public API: It returns , not the schema type it was handed. A handler can replace any node with a schema of a different type, so echoing the input type back is a claim the traversal cannot keep — every caller has to declare what its own rewrite produces, which is what is for. Anything non-trivial has to carry its own state beside the traversal rather than threading an accumulator through it. Keeping it in and out of the public barrels leaves room to replace it with something better without a deprecation cycle. User-defined s still pass through untouched, and the check still turns a missed built-in into a compile error. Changes — the traversal primitive. Internal; not re-exported from or either flavor barrel. and — / runtime projections. — for classic (object → , discriminated union → plain union, structural return type). — same for mini, wired to . — brief coverage for and / . and — re-export , , . — 39 tests covering: shallow + 3-level nested objects, arrays, tuples + rest, unions, discriminated union (degradation), intersection, record / map / set, promise, optional / nullable, wrapper composition (default + readonly + optional), recursive (lazy) schemas (cycle termination), primitive leaf preservation, transform passthrough, pipe in/out recursion, traversal identity, targeted def.type rewrites, shared sub-schema visit-once, unknown def.type passthrough, catchall traversal, non-lazy getter cycles, already-resolved lazies, structural-return-type assertions, and -on-output preservation. Test plan [x] — full suite green, 0 type errors. [x] Cycle case verified via the recursive schema test. [x] Structural-return-type assertions cover / / on result. [x] Full suite on CI.
Repository: colinhacks/zod. Description: TypeScript-first schema validation with static type inference Stars: 43463, Forks: 2124. Primary language: TypeScript. Languages: TypeScript (89.8%), MDX (9.1%), HTML (0.6%), JavaScript (0.3%), CSS (0.1%). License: MIT. Homepage: https://zod.dev Topics: runtime-validation, schema-validation, static-types, type-inference, typescript. Latest release: v4.4.3 (3mo ago). Open PRs: 100, open issues: 88. Last activity: 6h ago. Community health: 85%. Top contributors: colinhacks, JacobWeisenburger, scotttrinh, jeremyBanks, pullfrog[bot], samchungy, igalklebanov, tmcw, noritaka1166, alexxander and others.