What Changed in Zod 4, and How We Migrated Production Schemas
Table of Contents
About the Author

Ahmed Mahmoud
Author & Developer
Software engineer passionate about web development and user experience design.
Founder of Devya · eng-ahmed.com ↗Key takeaways
- Zod 4 is the stable major of the TypeScript-first schema validator, released in 2025, focused on faster type-checking, faster runtime parsing, and a smaller core; it requires TypeScript 5.5 or newer.
- String formats are now top-level tree-shakeable functions — z.email(), z.uuid(), z.url() — and the chained form z.string().email() is deprecated but still works.
- A single error parameter replaces Zod 3's four options: message, invalid_type_error, required_error, and errorMap; pass a string or a function that receives the issue.
- Error formatting moved to three standalone helpers: z.flattenError for form field errors, z.treeifyError for nested shapes, and z.prettifyError for a human-readable string.
- The zod/mini build exposes the same validators through a functional, tree-shakeable API for size-critical bundles, while z.infer, .parse(), and .safeParse() did not change.
At Devya we reach for Zod on almost every project to validate untrusted input at the boundary — request bodies, form data, environment variables, third-party API responses. Zod 4 changed enough of the surface that a mechanical upgrade tripped a handful of files across our codebases, so we mapped exactly what moved. These are our field notes.
What actually changed in Zod 4?
Zod 4 is a ground-up rewrite of the TypeScript-first schema validation library, released as the stable major in 2025. The headline is performance: the Zod team's release notes report large reductions in TypeScript compiler instantiations and faster runtime parsing, which matters most in large codebases where schema types dominate type-check time.
Four API changes touched our code directly. String formats moved to top-level functions. The four error options collapsed into one. Error formatting moved to standalone helpers. And .strict()/.passthrough() became z.strictObject()/z.looseObject(). Zod 4 also added a zod/mini build for smaller bundles and built-in JSON Schema conversion through z.toJSONSchema().
Why did z.string().email() become z.email()?
Zod 4 promotes string formats to standalone top-level functions — z.email(), z.uuid(), z.url(), and the ISO helpers under z.iso — instead of methods chained onto z.string(). The chained form z.string().email() still works but is deprecated and logs a warning.
The reason is tree-shaking: each format is its own function, so a bundle that only validates emails no longer ships the validation logic for every other format it never uses. In an object schema, the id field becomes z.uuid(), the email field z.email(), an optional website z.url().optional(), and the TypeScript type is derived with z.infer of the schema.
How do I set custom error messages in Zod 4?
Zod 4 replaces four separate error options with a single error parameter. In Zod 3 you passed message, invalid_type_error, required_error, or a full errorMap depending on the case.
In Zod 4 you pass one error: a string for a fixed message, or a function that receives the issue and returns a message. That single function can distinguish a missing value from a wrong type by checking whether issue.input is undefined, all in one place.
How do I turn a ZodError into form errors?
Zod 4 moves error formatting out of methods on the error object and into three top-level helpers. z.flattenError(error) returns an object with formErrors and fieldErrors, which maps cleanly onto a form's field-level messages. z.treeifyError(error) returns a nested object mirroring the schema shape, for deeply nested data. z.prettifyError(error) returns a human-readable multiline string for logs and CLIs.
The raw error.issues array is unchanged, and the older error.format() and error.flatten() methods are deprecated in favor of these helpers. A typical route handler calls safeParse on the request body; on failure it reads fieldErrors from z.flattenError and returns them with a 400 status, and on success the data property is fully typed.
When should we use zod/mini instead of the full zod package?
Use zod/mini when bundle size matters — client code, edge functions, a shipped widget — and use the full zod package everywhere else. zod/mini exposes the same validators through a functional API: instead of chaining .optional() you wrap with z.optional(), and refinements use .check() rather than .refine().
That is more verbose, but because there is no method chain, unused code tree-shakes away and the bundle shrinks. Both builds share the same core, so a schema's runtime behavior is identical. In short: chained methods and readability in the full package; functional wrappers and a smaller footprint in zod/mini.
How do we migrate from Zod 3 without breaking everything?
Migrate incrementally, because Zod 4 keeps the deprecated Zod 3 APIs working with warnings rather than removing them outright. During the transition Zod published the new version under the zod/v4 import path, in the zod@3.25 release, so teams could adopt it file by file before zod@4.0 landed; the legacy API stays reachable at zod/v3.
Our process: upgrade the package, run the type-checker and the test suite, then clear the deprecation warnings in waves — rename string-format calls, collapse the error options into a single error, and switch .strict()/.passthrough() to z.strictObject()/z.looseObject(). Because z.infer, .parse(), and .safeParse() did not change, most schemas kept working untouched.
FAQ
**Q:** Is z.string().email() removed in Zod 4?
**A:** No. It still works but is deprecated and logs a warning. The recommended form is the top-level z.email(), and both validate identically.
**Q:** What replaced errorMap and invalid_type_error in Zod 4?
**A:** A single error parameter. Pass a string for a fixed message, or a function that receives the issue for conditional messages such as distinguishing a missing value from a wrong type.
**Q:** How do we get field-level errors for a form in Zod 4?
**A:** Call z.flattenError(error); its fieldErrors maps each schema key to its messages. Use z.treeifyError(error) for deeply nested shapes.
**Q:** Is zod/mini a separate library?
**A:** No. It is a build of Zod 4 with the same validators exposed through a functional, tree-shakeable API for smaller bundles.
**Q:** Does Zod 4 require a specific TypeScript version?
**A:** Yes. Zod 4 requires TypeScript 5.5 or newer.
Further Reading
AI & Dev Tools
Streaming AI Responses in Next.js: What Actually Breaks in Production
Every AI feature we ship at Devya streams its output, and the streaming code is the easy part. Our field notes on SSE over fetch in the Next.js App Router: the proxy buffering that freezes streams in production, the abort wiring that stops wasted token spend, and what a refresh really does to an in-flight generation.
Frontend Engineering
Route Handlers vs Server Actions in Next.js: When Our Team Reaches for Each
Server Actions and Route Handlers both run server code in the Next.js App Router, but they solve different problems. At Devya we use Server Actions for mutations from our own UI and Route Handlers for public APIs, webhooks, and streaming — and we treat both as public endpoints.