Migration guide
To learn more about the performance enhancements and new features of Zod 4, read the introductory post.
This migration guide aims to list the breaking changes in Zod 4 in order of highest to lowest impact. Every effort was made to prevent breaking changes, but some are unavoidable.
Note — Zod 3 exported a large number of undocumented, internal utility types and functions that are not considered part of the public API. Changes to those are not documented here.
To install the beta:
Error customization
This is perhaps the most visible of the removed APIs. Zod 4 standardizes the APIs for error customization under a single, unified error
param.
deprecates message
Replace message
with error
. The message
parameter is still supported but deprecated.
drops invalid_type_error
and required_error
The invalid_type_error
/ required_error
params have been dropped. These were hastily added years ago as a way to customize errors that was less verbose than errorMap
. They came with all sorts of footguns (they can't be used in conjunction with errorMap
) and do not align with Zod's actual issue codes (there is no required
issue code).
These can now be cleanly represented with the new error
parameter.
drops errorMap
This is renamed to error
. Error maps can also now return a string or undefined
(which yields control to the next error map in the chain).
ZodError
no longer extends Error
It is very slow to instantiate Error
instances in JavaScript, as the initialization process snapshots the call stack. The magnitude of this performance hit is too much to justify, and extending Error
adds little value anyway. The .stack
trace includes lots of Zod internals and can be confusing.
In Zod 4 the ZodError
class no longer extends the plain JavaScript Error
class. Instead, ZodError
implements
the Error
interface. This means all error-handling code should still work with the exception of instanceof Error
checks.
updates issue formats
The issue formats have been dramatically streamlined.
Below is the list of Zod 3 issues types and their Zod 4 equivalent:
While certain Zod 4 issue types have been merged, dropped, and modified, each issue remains structurally similar to Zod 3 counterpart (identical, in most cases). All issues still conform to the same base interface as Zod 3, so most common error handling logic will work without modification.
changes error map precedence
The error map precedence has been changed to be more consistent. Specifically, an error map passed into .parse()
no longer takes precedence over a schema-level error map.
deprecates .format()
The .format()
method on ZodError
has been deprecated. Instead use the top-level z.treeifyError()
function. Read the Formatting errors docs for more information.
deprecates .flatten()
The .flatten()
method on ZodError
has also been deprecated. Instead use the top-level z.treeifyError()
function. Read the Formatting errors docs for more information.
drops .formErrors
This API was identical to .flatten()
. It exists for historical reasons and isn't documented.
deprecates .addIssue()
and .addIssues()
Directly push to err.issues
array instead, if necessary.
z.number()
no infinite values
POSITIVE_INFINITY
and NEGATIVE_INFINITY
are no longer considered valid values for z.number()
.
.int()
accepts safe integers only
The z.number().int()
API no longer accepts unsafe integers (outside the range of Number.MIN_SAFE_INTEGER
and Number.MAX_SAFE_INTEGER
). Using integers out of this range causes spontaneous rounding errors.
z.string()
updates
deprecates .email()
etc
String formats are now represented as subclasses of ZodString
, instead of simple internal refinements. As such, these APIs have been moved to the top-level z
namespace. Top-level APIs are also less verbose and more tree-shakable.
The method forms (z.string().email()
) still exist and work as before, but are now deprecated.
drops z.string().ip()
This has been replaced with separate .ipv4()
and .ipv6()
methods. Use z.union()
to combine them if you need to accept both.
updates z.string().ipv6()
Validation now happens using the new URL()
constructor, which is far more robust than the old regular expression approach. Some invalid values that passed validation previously may now fail.
drops z.string().cidr()
Similarly, this has been replaced with separate .cidrv4()
and .cidrv6()
methods. Use z.union()
to combine them if you need to accept both.
z.coerce
updates
The input type of all coerced booleans is now unknown
.
z.object()
These modifier methods on the ZodObject
class determine how the schema handles unknown keys. In Zod 4, this functionality now exists in top-level functions. This aligns better with Zod's declarative-first philosophy, and puts all object variants on equal footing.
deprecates .strict()
deprecates .passthrough()
deprecates .strip()
This was never particularly useful, as it was the default behavior of z.object()
.
The equivalents for z.interface()
also exist. Read more about z.interface()
here.
drops .nonstrict()
This long-deprecated alias for .strip()
has been removed.
drops .deepPartial()
This has been long deprecated in Zod 3 and it now removed in Zod 4. There is no direct alternative to this API. There were lots of footguns in its implementation, and its use is generally an anti-pattern.
changes z.unknown()
optionality
The z.unknown()
and z.any()
types are no longer marked as "key optional" in the inferred types.
z.nativeEnum()
deprecated
The z.nativeEnum()
method is deprecated in favor of just z.enum()
. The z.enum()
API has been overloaded to support both.
As part of this refactor of ZodEnum
, a number of long-deprecated and redundunant features have been removed. These were all identical and only existed for historical reasons.
z.array()
changes .nonempty()
type
This now behaves identically to z.array().min(1)
. The inferred type does not change.
The old behavior is now better represented with z.tuple()
and a "rest" argument. This aligns more closely to TypeScript's type system.
z.promise()
deprecated
There's rarely a reason to use z.promise()
. If you have an input that may be a Promise
, just await
it before parsing it with Zod.
If you are using z.promise
to define an async function with z.function()
, that's no longer necessary either; see the ZodFunction
section below.
z.function()
The result of z.function()
is no longer a Zod schema. Instead, it acts as a standalone "function factory" for defining Zod-validated functions. The API has also changed; you define an input
and output
schema upfront, instead of using args()
and .returns()
methods.
adds .implementAsync()
To define an async function, use implementAsync()
instead of implement()
.
.refine()
ignores type predicates
In Zod 3, passing a type predicate as a refinement functions could still narrow the type of a schema. This wasn't documented but was discussd in some issues. This is no longer the case.
z.ostring()
, etc dropped
The undocumented convenience methods z.ostring()
, z.onumber()
, etc. have been removed. These were shorthand methods for defining optional string schemas.
z.literal()
drops symbol
support
Symbols aren't considered literal values, nor can they be simply compared with ===
. This was an oversight in Zod 3.
.create()
factories dropped
Previously all Zod classes defined a static .create()
method. These are now implemented as standalone factory functions.
z.discriminatedUnion()
You no longer need to specify a discriminator key (though you still can if you wish; it is ignored).
z.record()
drops single argument usage
Before, z.record()
could be used with a single argument. This is no longer supported.
improves enum support
Records have gotten a lot smarter. In Zod 3, passing an enum into z.record()
as a key schema would result in a partial type
In Zod 4, this is no longer the case. The inferred type is what you'd expect, and Zod ensures exhaustiveness; that is, it makes sure all enum keys exist in the input during parsing.
z.intersection()
throws Error
on merge conflict
Zod intersection parses the input against two schemas, then attempts to merge the results. In Zod 3, when the results were unmergable, Zod threw a ZodError
with a special "invalid_intersection_types"
issue.
In Zod 4, this will throw a regular Error
instead. The existence of unmergable results indicates a structural problem with the schema: an intersection of two incompatible types. Thus, a regular error is more appropriate than a validation error.
Internal changes
The typical user of Zod can likely ignore everything below this line. These changes do not impact the user-facing z
APIs.
There are too many internal changes to list here, but some may be relevant to regular users who are (intentionally or not) relying on certain implementation details. These changes will be of particular interest to library authors building tools on top of Zod.
updates generics
The generic structure of several classes has changed. Perhaps most significant is the change to the ZodType
base class:
The second generic Def
has been entirely removed. Instead the base class now only tracks Output
and Input
. While previously the Input
value defaulted to Output
, it now defaults to unknown
. This allows generic functions involving z.ZodType
to behave more intuitively in many cases.
The need for z.ZodTypeAny
has been eliminated; just use z.ZodType
instead.
adds z.core
Many utility functions and types have been moved to the new @zod/core
package, to facilitate code sharing between zod
and @zod/mini
. The contents of @zod/core
from zod
/@zod/mini
using the z.core
namespace. Check z.core
if any internal APIs you rely on are missing; they've likely been moved there.
moves ._def
The ._def
property is now moved to ._zod.def
. The structure of all internal defs is subject to change; this is relevant to library authors but won't be comprehensively documented here.
drops ZodEffects
This doesn't affect the user-facing APIs, but it's an internal change worth highlighting. It's part of a larger restructure of how Zod handles refinements.
Previously both refinements and transformations lived inside a wrapper class called ZodEffects
. That means adding either one to a schema would wrap the original schema in a ZodEffects
instance. In Zod 4, refinements now live inside the schemas themselves. More accurately, each schema contains an array of "checks"; the concept of a "check" is new in Zod 4 and generalizes the concept of a refinement to include potentially side-effectful transforms like z.toLowerCase()
.
This is particularly apparent in the @zod/mini
API, which heavily relies on the .check()
method to compose various validations together.
adds ZodTransform
Meanwhile, transforms have been moved into a dedicated ZodTransform
class. This schema class represents an input transform; in fact, you can actually define standalone transformations now:
This is primarily used in conjunction with ZodPipe
. The .transform()
method now returns an instance of ZodPipe
.
drops ZodPreprocess
As with .transform()
, the z.prepreprocess()
function now returns a ZodPipe
instance instead of a dedicated ZodPreprocess
instance.
drops ZodBranded
Branding is now handled with a direct modification to the inferred type, instead of a dedicated ZodBranded
class. The user-facing APIs remain the same.