Zod v4, `.errors` is gone and coerce types now accept `unknown` — how to safely migrate your production codebase step by step
Starting in July 2025, running npm install zod installs v4. Being a major version bump, you'd expect something dramatically different — but once installed, most of your existing TypeScript code compiles and runs without errors. Then, not long after, you start noticing API error responses coming back as empty objects. Nothing in the logs, not a single complaint from TypeScript.
This post covers three key breaking changes. ZodError.errors has been removed and replaced with .issues — in certain patterns, it silently returns undefined at runtime with no TypeScript error. The input type of z.coerce.* changed from string to unknown, causing type conflicts when integrating with form libraries like react-hook-form. And the z.string().trim().email() chaining pattern no longer works — you need to restructure it with .pipe(z.email()).
If you want to stay on v3, you can install it explicitly with zod@3. That said, v4's performance gains (6.5× for object safeParse, 14× for string parsing), built-in JSON Schema conversion, and faster TypeScript builds are genuinely compelling. Let's walk through what to tackle first and in what order.
Core Concepts
.coerce — Input Type Changed from string to unknown
In v3, the input type of z.coerce.number() was inferred as string. This reflected the assumption that form data always comes in as strings — but coerce actually accepts any value and attempts conversion, so string was an inaccurate representation. v4 corrects this: z.input<typeof schema> now returns unknown.
// v3: input type inferred as string
const schemaV3 = z.coerce.number();
type V3Input = z.input<typeof schemaV3>; // string
// v4: input type changed to unknown
const schemaV4 = z.coerce.number();
type V4Input = z.input<typeof schemaV4>; // unknownThe type is more accurate now, but any existing code that assumed z.input<typeof schema> was string will produce new TypeScript errors. This change is especially problematic when integrating with react-hook-form's zodResolver.
ZodError Restructuring — Where .errors Quietly Disappears
This is the most dangerous change.
ZodError.errorshas been removed in v4. In strongly-typed code (the return value ofsafeParse()), it's caught immediately as a TypeScript compile error. But incatch (e)orerr: anypatterns, it returnsundefinedat runtime, producing empty error responses.
Here's the key distinction. When you use safeParse(), result.error is inferred as a strongly-typed ZodError, so accessing .errors produces a compile error — TypeScript catches it. The problem is when you wrap .parse() in a try-catch, or when any types enter the picture through something like Express error middleware.
// ✅ Strongly-typed code — caught at compile time
const result = userSchema.safeParse(req.body);
if (!result.success) {
result.error.errors; // TypeScript compile error in v4 → caught immediately
}
// ❌ Dangerous pattern — TypeScript passes silently
try {
userSchema.parse(req.body);
} catch (e) {
// e is unknown → TypeScript cannot check .errors
const zodErr = e as ZodError;
console.log(zodErr.errors); // returns undefined in v4 — runtime bug
}
// ❌ Express error middleware pattern
app.use((err: any, req: Request, res: Response, next: NextFunction) => {
if (err instanceof ZodError) {
return res.status(400).json({ errors: err.errors }); // returns undefined
}
});Here's a summary of the error-handling changes:
| v3 | v4 | Notes |
|---|---|---|
error.errors |
error.issues |
Property removed |
error.formErrors |
z.flattenError(error).formErrors |
Replace direct usage |
error.format() |
z.treeifyError(error) |
deprecated → standalone function |
error.flatten() |
z.flattenError(error) |
deprecated → standalone function |
The correct v4 approach looks like this:
if (result.error) {
console.log(result.error.issues); // ✅ Drop-in replacement — returns ZodIssue[]
console.log(z.flattenError(result.error)); // ✅ { fieldErrors, formErrors }
console.log(z.treeifyError(result.error)); // ✅ Nested tree structure
}The output structure of treeifyError() differs from v3's format(). The _errors key becomes errors, and nested fields move under a properties key.
const schema = z.object({ email: z.email() });
const result = schema.safeParse({ email: "not-valid" });
// v3: error.format()
// {
// "_errors": [],
// "email": { "_errors": ["Invalid email"] }
// }
// v4: z.treeifyError(result.error)
// {
// "errors": [],
// "properties": {
// "email": { "errors": ["Invalid email"] }
// }
// }If you have client-side code that parses error responses, you'll need to update it to reflect the _errors → errors and fieldName → properties.fieldName structural change.
z.string().trim().email() — Why the Chain Breaks
In v4, .trim(), .toLowerCase(), and .toUpperCase() were reimplemented internally using .overwrite(). At the same time, z.string().email() was deprecated and split out as a standalone z.email() function.
These two changes together mean the old z.string().trim().email() pattern no longer works. Because .trim() transforms the value internally, the result must be validated by a separate z.email() schema — connected via .pipe().
// v3
const emailField = z.string().trim().toLowerCase().email("Invalid email format");
// v4 — explicitly separate transformation and validation using .pipe()
const emailField = z
.string()
.trim()
.toLowerCase()
.pipe(z.email("Invalid email format"));Using .pipe() makes the boundary between transformation and validation explicit, which actually clarifies intent. z.preprocess() is an alternative, but use it when you need finer control over transformation logic — for example, when the input type might not be string. .pipe() is sufficient for most cases.
Practical Application
Scenario 1 — API Endpoint Returns Empty Error Responses
This is the first thing to address, and the hardest to find. Here's a common Express error middleware pattern from v3:
// v3 pattern — silently breaks in any-typed error handlers
app.use((err: any, req: Request, res: Response, next: NextFunction) => {
if (err instanceof ZodError) {
return res.status(400).json({
errors: err.errors, // ❌ returns undefined in v4 — TypeScript won't catch this
});
}
next(err);
});The true drop-in replacement for .errors is .issues. Both return a ZodIssue[] array, so the client response structure stays the same.
// v4 migration — drop-in replacement
app.use((err: any, req: Request, res: Response, next: NextFunction) => {
if (err instanceof ZodError) {
return res.status(400).json({
errors: err.issues, // ✅ ZodIssue[] — preserves client structure
});
}
next(err);
});If you need errors grouped by field, use flattenError(). Note that this changes the response structure from an array to an object, so client-side code will need to be updated as well.
// When you need per-field error objects — involves client spec changes
app.post("/users", async (req, res) => {
const result = userSchema.safeParse(req.body);
if (!result.success) {
return res.status(400).json({
errors: z.flattenError(result.error).fieldErrors, // { email: ["..."], age: ["..."] }
});
}
});Which approach to use depends on the error structure your client expects.
Scenario 2 — Type Conflict Between react-hook-form and coerce
// v3: age in FormInput inferred as string
const formSchema = z.object({
age: z.coerce.number().min(0).max(120),
});
type V3FormInput = z.input<typeof formSchema>;
// { age: string }
// v4: age changed to unknown
type V4FormInput = z.input<typeof formSchema>;
// { age: unknown } ← type conflict in older versions of zodResolverIf you're on @hookform/resolvers below v5, this type change will cause form type conflicts. Upgrading to @hookform/resolvers@5 or later properly handles v4's unknown input type.
Scenario 3 — Error Message Customization API Changes
In v3, you specified required_error and invalid_type_error separately. In v4, these are unified under a single error parameter.
// v3
const schema = z.string({
required_error: "This field is required",
invalid_type_error: "Must be a string",
});
// v4 — simple unified form
const schema = z.string({
error: "Must be a string",
});
// v4 — fine-grained control with a function
const schema = z.string({
error: (issue) =>
issue.input === undefined ? "This field is required" : "Must be a string",
});This transformation is repetitive enough to automate with a codemod.
Scenario 4 — Missing Second Argument in z.record()
In v4, z.record() requires explicit key and value schemas. v3 allowed a single argument (z.record(z.string())), but v4 now supports key types beyond z.string() — including z.enum(), z.union(), and others — so both arguments must be explicitly provided.
// v3: key schema optional — implicitly string
const v3record = z.record(z.string());
// v4: both key and value schemas required
const v4record = z.record(z.string(), z.string()); // ✅Calling z.record(z.string()) with a single argument in v4 produces a TypeScript error, so it's visible at compile time and relatively easy to find.
Using a Codemod to Reduce Repetitive Work
Repetitive patterns like required_error, invalid_type_error, and .format() are good candidates for a codemod.
# Dry run first to see what will change
npx codemod@latest zod/v3-to-v4 --dry-run ./src
# Apply the changes
npx codemod@latest zod/v3-to-v4 ./srcThe codemod adds TODO(zod-4-migration) comments to ambiguous cases. After the automated pass, search for these comments and handle them manually to minimize missed spots.
Tackling high-risk items first is essential.
What Else You Gain in v4 — Built-in JSON Schema Support
Built-in JSON Schema generation is an optional improvement, not a migration requirement. If you were using an external package like zod-to-json-schema, you can replace it with z.toJSONSchema() in v4.
const userSchema = z.object({
id: z.uuid(),
email: z.email(),
age: z.number().int().min(0),
});
const jsonSchema = z.toJSONSchema(userSchema);
// Can be used directly for OpenAPI spec generationUnless you have a specific reason beyond dropping an external dependency, this migration can wait.
Pros and Cons
What You Gain After Migration
| Item | Details |
|---|---|
| Parsing performance | 14× for strings, 7× for arrays, 6.5× for object safeParse |
| TypeScript build speed | 20× fewer type instantiations — one large monorepo went from 47s to 5s with tsc |
| Bundle size | Core bundle reduced by 2×, with additional savings using @zod/mini |
| Error API consistency | Unified error parameter improves code predictability |
| Built-in JSON Schema | Can remove zod-to-json-schema external dependency |
| Standard Schema compatibility | Foundation for interoperability with valibot, arktype, and others |
What You Accept During Migration
| Item | Details |
|---|---|
.errors silent bug |
Returns undefined in any/unknown typed error handlers — highest risk |
treeifyError structure change |
_errors → errors, nested fields moved under properties — requires client parsing code updates |
z.record() mass update |
All single-argument patterns must be replaced |
.optional().default() order |
In v4, type inference varies by chaining order. Verify affected schemas with tests to confirm defaults are actually applied. |
| Integration library compatibility | Verify v4 support for drizzle-zod, @hookform/resolvers, and tRPC |
Common Mistakes in Practice
Mistake 1 — Leaving .errors as-is
Don't be reassured just because TypeScript is quiet after installing v4. safeParse() return values are caught at compile time, but try-catch blocks and Express middleware where errors flow in as any are not. Run a grep to find all usages.
grep -rn "\.errors\b" ./src --include="*.ts"Mistake 2 — Trusting the Codemod and Skipping Manual Review
The codemod handles mechanically repetitive patterns well, but it can't catch type flow changes that only manifest at runtime. The z.coerce.* input type change and the treeifyError() output structure change require manual verification.
Mistake 3 — Skipping Integration Library Version Checks
A common scenario: you upgrade zod itself to v4, but @hookform/resolvers is still on an old version, and forms start behaving strangely. This typically shows up as mangled type inference in forms with coerce fields. When upgrading to zod v4, check the release notes for @hookform/resolvers, drizzle-zod, and tRPC as well.
Wrapping Up
Here's a summary of the key v4 changes. error.errors must be replaced with error.issues — this is the most important thing to do first, since it creates runtime bugs in any-typed error handlers. z.coerce.*'s input type changed to unknown, so you need to check versions when integrating with form libraries. The z.string().trim().email() chain must be restructured as .pipe(z.email()) — this one will be caught immediately as a type error.
Three steps you can take right now:
- Run
grep -rn "\.errors\b" ./srcto find all.errorsusages and replace them witherror.issuesandz.flattenError()/z.treeifyError(). - Run
npx codemod@latest zod/v3-to-v4 --dry-run ./srcfirst to see which patterns the codemod handles automatically. - Upgrade
@hookform/resolvers,drizzle-zod, and other integration libraries to their v4-compatible versions to prevent type conflicts upfront.
References
- Zod v4 Official Release Notes
- Zod v4 Migration Guide
- Zod v4 Error Formatting Official Docs
- Zod JSON Schema Official Docs
- Migrating to Zod 4: The Complete Guide (DEV Community)
- Lessons from Upgrading a Large TypeScript App to Zod 4 (Viget)
- Lessons Learned Migrating a Large Production Codebase from Zod v3 to v4 (Hashnode)
- Zod v4 Migration Guide for TypeScript Validation (BuildMVPFast)
- ZodError.errors removed in v4 — GitHub Issue #5063
- z.string().trim().email() deprecated issue — GitHub Issue #4850
- How to trim an email address with Zod v4? — GitHub Issue #4642
- Zod v4 Available with Major Performance Improvements — InfoQ
- Here's why everyone's going crazy over Zod 4 (LogRocket Blog)
- nicoespeon/zod-v3-to-v4 codemod (GitHub)
- Zod 3 to 4 Migration — Codemod.com
- Zod v3 Compatibility and Migration — DeepWiki