Introducing Zod 4 beta
Refer to the Changelog for a complete list of breaking changes.
Zod 4 is now in beta after over a year of active development. It's faster, slimmer, more tsc
-efficient, and implements some long-requested features.
To install the beta:
Development will continue on the v4
branch over a 4-6 week beta period as I work with libraries to ensure day-one compatibility with the first stable release.
Huge thanks to Clerk, who supported my work on Zod 4 through their extremely generous OSS Fellowship. They were an amazing partner throughout the (much longer than anticipated!) development process.
Why a new major version?
Zod v3.0 was released in May 2021 (!). Back then Zod had 2700 stars on GitHub and 600k weekly downloads. Today it has 36.5k stars and 23M weekly downloads. After 24 minor versions, the Zod 3 codebase has hit a ceiling; the most commonly requested features and improvements require breaking changes.
Zod 4 implements all of these in one fell swoop. It uses an entirely new internal architecture that solves some long-standing design limitations, lays the groundwork for some long-requested features, and closes 9 of Zod's 10 most upvoted open issues. With luck, it will serve as the new foundation for many more years to come.
For a scannable breakdown of what's new, see the table of contents. Click on any item to jump to that section.
Benchmarks
You can run these benchmarks yourself in the Zod repo:
Then to run a particular benchmark:
2.6x faster string parsing
3x faster array parsing
7x faster object parsing
This runs the Moltar validation library benchmark.
20x reduction in tsc
instantiations
Consider the following simple file:
Compiling this file with tsc --extendedDiagnostics
using zod@3
results in >25000 type instantiations. With zod@4
it only results in ~1100.
The Zod repo contains a tsc
benchmarking playground. Try this for yourself using the compiler benchmarks in packages/tsc
. The exact numbers may change as the implementation evolves.
More importantly, Zod 4 has redesigned and simplified the generics of ZodObject
and other schema classes to avoid some pernicious "instantiation explosions". For instance, chaining .extend()
and .omit()
repeatedly—something that previously caused compiler issues:
In Zod 3, this took 4000ms
to compile; and adding additional calls to .extend()
would trigger a "Possibly infinite" error. In Zod 4, this compiles in 400ms
, 10x
faster.
Coupled with the upcoming tsgo
compiler, Zod 4's editor performance will scale to vastly larger schemas and codebases.
2x reduction in core bundle size
Consider the following simple script.
It's about as simple as it gets when it comes to validation. That's intentional; it's a good way to measure the core bundle size—the code that will end up in the bundle even in simple cases. We'll bundle this with rollup
using both Zod 3 and Zod 4 and compare the final bundles.
Package | Bundle (gzip) |
---|---|
zod@3 | 12.47kb |
zod@4 | 5.36kb |
The core bundle is ~57% smaller in Zod 4 (2.3x). That's good! But we can do a lot better.
Introducing @zod/mini
Zod's method-heavy API is fundamentally difficult to tree-shake. Even our simple z.boolean()
script pulls in the implementations of a bunch of methods we didn't use, like .optional()
, .array()
, etc. Writing slimmer implementations can only get you so far. That's where @zod/mini
comes in.
It's a sister library with a functional, tree-shakable API that corresponds one-to-one with zod
. Where Zod uses methods, @zod/mini
generally uses wrapper functions:
Not all methods are gone! The parsing methods are identical in zod
and @zod/mini
.
There's also a general-purpose .check()
method used to add refinements.
The following top-level refinements are available in @zod/mini
. It should be fairly self-explanatory which zod
methods they correspond to.
This more functional API makes it easier for bundlers to tree-shaking the APIs you don't use. While zod
is still recommended for the majority of use cases, any projects with uncommonly strict bundle size constraints should consider @zod/mini
.
6.6x reduction in core bundle size
Here's the script from above, updated to use "@zod/mini"
instead of "zod"
.
When we build this with rollup
, the gzipped bundle size is 1.88kb
. That's an 85% (6.6x) reduction in core bundle size compared to zod@3
.
Package | Bundle (gzip) |
---|---|
zod@3 | 12.47kb |
zod@4 | 5.36kb |
@zod/mini | 1.88kb |
Learn more on the dedicated @zod/mini
docs page. Complete API details are mixed into existing documentation pages; code blocks contain separate tabs for zod
and @zod/mini
wherever their APIs diverge.
Metadata
Zod 4 introduces a new system for adding strongly-typed metadata to your schemas. Metadata isn't stored inside the schema itself; instead it's stored in a "schema registry" that associates a schema with some typed metadata. To create a registry with z.registry()
:
To add schemas to your registry:
Alternatively, you can use the .register()
method on a schema for convenience:
The global registry
Zod also exports a global registry z.globalRegistry
that accepts some common JSON Schema-compatible metadata:
.meta()
To conveniently add a schema to z.globalRegistry
, use the .meta()
method.
For compatibility with Zod 3, .describe()
is still available, but .meta()
is preferred.
JSON Schema conversion
Zod 4 introduces first-party JSON Schema conversion via z.toJSONSchema()
.
Any metadata in z.globalRegistry
is automatically included in the JSON Schema output.
Refer to the JSON Schema docs for information on customizing the generated JSON Schema.
z.interface()
Zod 4 introduces a new API for defining object types: z.interface()
. This may seem surprising or confusing, so I'll briefly explain the reasoning here. (A full blog post on this topic is coming soon.)
Exact(er) optional properties
In TypeScript a property can be "optional" in two distinct ways:
In KeyOptional
, the prop
key can be omitted from the object ("key optional"). In ValueOptional
, the prop
key must be set however it can be set to undefined
("value optional").
Zod 3 cannot represent ValueOptional
. Instead, z.object()
automatically adds question marks to any key that accepts a value of undefined
:
This includes special schema types like z.unknown()
:
To properly represent "key optionality", Zod needed an object-level API for marking keys as optional, instead of trying to guess based on the value schema.
This is why Zod 4 introduces a new API for defining object types: z.interface()
.
Key optionality is now defined with a ?
suffix in the key itself. This way, you have the power to differentiate between key- and value-optionality.
Besides this change to optionality, z.object()
and z.interface()
are functionality identical. They even use the same parser internally.
The z.object()
API is not deprecated; feel free to continue using it if you prefer it! For the sake of backwards compatibility, z.interface()
was added as an opt-in API.
True recursive types
But wait there's more! After implementing z.interface()
, I had a huge realization. The ?
-suffix API in z.interface()
lets Zod sidestep a TypeScript limitation that has long prevented Zod from cleanly representing recursive (cyclical) types. Take this example from the old Zod 3 docs:
This has been a thorn in my side for years. To define a cyclical object type, you must
- define a redundant interface
- use
z.lazy()
to avoid reference errors - cast your schema to
z.ZodType
That's terrible.
Here's the same example in Zod 4:
No casting, no z.lazy()
, no redundant type signatures. Just use getters to define any cyclical properties. The resulting instance has all the object methods you expect:
This means Zod can finally represent commonly cyclical data structure like ORM schemas, GraphQL types, etc.
Given its ability to represent both cyclical types and more exact optionality, I recommend always using z.interface()
over z.object()
without reservation. That said, z.object()
will never be deprecated or removed, so feel free to keep using it if you prefer.
File schemas
To validate File
instances:
Internationalization
Zod 4 introduces a new locales
API for globally translating error messages into different languages.
At the time of this writing only the English locale is available; There will be a call for pull request from the community shortly; this section will be updated with a list of supported languages as they become available.
Error pretty-printing
The success of the zod-validation-error
package demonstrates that there's significant demand for an official API for pretty-printing errors. If you are using that package currently, by all means continue using it.
Zod now implements a top-level z.prettifyError
function for converting a ZodError
to a user-friendly formatted string.
This returns the following pretty-printable multi-line string:
Currently the formatting isn't configurable; this may change in the future.
Top-level string formats
All "string formats" (email, etc.) have been promoted to top-level functions on the z
module. This is both more concise and more tree-shakable. The method equivalents (z.string().email()
, etc.) are still available but have been deprecated. They'll be removed in the next major version.
Custom email regex
The z.email()
API now supports a custom regular expression. There is no one canonical email regex; different applications may choose to be more or less strict. For convenience Zod exports some common ones.
Template literal types
Zod 4 implements z.templateLiteral()
. Template literal types are perhaps the biggest feature of TypeScript's type system that wasn't previously representable.
Every Zod schema type that can be stringified stores an internal regex: strings, string formats like z.email()
, numbers, boolean, bigint, enums, literals, undefined/optional, null/nullable, and other template literals. The z.templateLiteral
constructor concatenates these into a super-regex, so things like string formats (z.email()
) are properly enforced (but custom refinements are not!).
Read the template literal docs for more info.
Number formats
New numeric "formats" have been added for representing fixed-width integer and float types. These return a ZodNumber
instance with proper minimum/maximum constraints already added.
Similarly the following bigint
numeric formats have also been added. These integer types exceed what can be safely represented by a number
in JavaScript, so these return a ZodBigInt
instance with the proper minimum/maximum constraints already added.
Stringbool
The existing z.coerce.boolean()
API is very simple: falsy values (false
, undefined
, null
, 0
, ""
, NaN
etc) become false
, truthy values become true
.
This is still a good API, and its behavior aligns with the other z.coerce
APIs. But some users requested a more sophisticated "env-style" boolean coercion. To support this, Zod 4 introduces z.stringbool()
:
To customize the truthy and falsy values:
Refer to the z.stringbool()
docs for more information.
Simplified error customization
The majority of breaking changes in Zod 4 involve the error customization APIs. They were a bit of a mess in Zod 3; Zod 4 makes things significantly more elegant, to the point where I think it's worth highlighting here.
Long story short, there is now a single, unified error
parameter for customizing errors, replacing the following APIs:
Replace message
with error
. (The message
parameter is still supported but deprecated.)
Replace invalid_type_error
and required_error
with error
(function syntax):
Replace errorMap
with error
(function syntax):
Upgraded z.discriminatedUnion()
Discriminated union support has improved in a couple ways. First, you no longer need to specify the discriminator key. Zod now has a robust way to identify the discriminator key automatically. If no shared discriminator key is found, Zod will throw an error at schema initialization time.
Discriminated unions schema now finally compose—you can use one discriminated union as a member of another. Zod determines the optimal discrimination strategy.
Multiple values in z.literal()
The z.literal()
API now optionally supports multiple values.
Refinements now live inside schemas
In Zod 3, they were stored in a ZodEffects
class that wrapped the original schema. This was inconvenient, as it meant you couldn't interleave .refine()
with other schema methods like .min()
.
In Zod 4, refinements are stored inside the schemas themselves, so the code above works as expected.
.overwrite()
The .transform()
method is extremely useful, but it has one major downside: the output type is no longer introspectable at runtime. The transform function is a black box that can return anything. This means (among other things) there's no sound way to convert the schema to JSON Schema.
Zod 4 introduces a new .overwrite()
method for representing transforms that don't change the inferred type. Unlike .transform()
, this method returns an instance of the original class. The overwrite function is stored as a refinement, so it doesn't (and can't) modify the inferred type.
The existing .trim()
, .toLowerCase()
and .toUpperCase()
methods have been reimplemented using .overwrite()
.
An extensible foundation: @zod/core
While this will not be relevant to the majority of Zod users, it's worth highlighting. The addition of @zod/mini
necessitated the creation of a third package @zod/core
that contains the core functionality shared between zod
and @zod/mini
.
I was resistant to this at first, but now I see it as one of Zod 4's most important features. It lets Zod level up from a simple library to a fast validation "substrate" that can be sprinkled into other libraries.
If you're building a schema library, refer to the implementations of zod
and @zod/mini
to see how to build on top of the foundation @zod/core
provides. Don't hesitate to get in touch in GitHub discussions or via X/Bluesky for help or feedback.
Wrapping up
I'm planning to write up a series of additional posts explaining the design process and rationale behind some major features like @zod/mini
and z.interface()
. I'll update this section as those get posted.
Zod 4 will remain in beta for roughly 6 weeks as I work with library authors and major adopters to ensure a smooth day-one transition from Zod 3 to Zod 4. I encourage all users of Zod to upgrade their installation and provide feedback during the beta window.
Happy parsing!
— Colin McDonnell @colinhacks