DEV Community

Ahmed Mahmoud
Ahmed Mahmoud

Posted on • Originally published at devya.dev

What Changed in Zod 4, and How I Migrated Production Schemas

Headline: Zod 4 is a rewrite of the TypeScript-first schema validation library, released as the stable major in 2025. Four changes hit my code directly: string formats moved to top-level functions (z.email() instead of z.string().email()), the four error options collapsed into one error parameter, error formatting moved to standalone helpers (z.flattenError, z.treeifyError, z.prettifyError), and .strict()/.passthrough() became z.strictObject()/z.looseObject(). The deprecated Zod 3 APIs still work with warnings, so I migrated incrementally.

Key takeaways

  • Zod 4 is the stable major of the TypeScript-first schema validator, released in 2025; it requires TypeScript 5.5 or newer.
  • String formats are now top-level tree-shakeable functions — z.email(), z.uuid(), z.url() — and z.string().email() is deprecated but still works.
  • A single error parameter replaces Zod 3's message, invalid_type_error, required_error, and errorMap.
  • Error formatting moved to z.flattenError (form fields), z.treeifyError (nested), and z.prettifyError (human-readable string).
  • The zod/mini build exposes the same validators through a functional, tree-shakeable API; z.infer, .parse(), and .safeParse() did not change.

I reach for Zod on almost every project to validate untrusted input at the boundary — request bodies, form data, environment variables, API responses. Zod 4 changed enough of the surface that a mechanical upgrade tripped a handful of files, so I mapped exactly what moved.

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 my 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().

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 still works but is deprecated and warns. The reason is tree-shaking: each format is its own function, so a bundle that only validates emails no longer ships the logic for every other format.

import * as z from "zod";

const User = z.object({
  id: z.uuid(),
  email: z.email(),
  website: z.url().optional(),
  age: z.number().int().min(18),
});

type User = z.infer<typeof User>;
Enter fullscreen mode Exit fullscreen mode

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. In Zod 4 you pass one error: a string for a fixed message, or a function that receives the issue and returns a message, letting you distinguish a missing value from a wrong type in one place.

// Zod 4: one error param — string or function
z.string({ error: "Name is required" });

z.string({
  error: (issue) =>
    issue.input === undefined ? "Name is required" : "Name must be text",
});
Enter fullscreen mode Exit fullscreen mode

How do I turn a ZodError into form errors?

Zod 4 moves error formatting into three top-level helpers. z.flattenError(error) returns { formErrors, fieldErrors }, which maps onto a form's field-level messages. z.treeifyError(error) returns a nested object mirroring the schema shape. z.prettifyError(error) returns a human-readable multiline string for logs. The raw error.issues array is unchanged; error.format() and error.flatten() are deprecated in favor of these helpers.

const result = User.safeParse(await request.json());

if (!result.success) {
  const { fieldErrors } = z.flattenError(result.error);
  return Response.json({ errors: fieldErrors }, { status: 400 });
}

const user = result.data; // fully typed as User
Enter fullscreen mode Exit fullscreen mode

When should I use zod/mini instead of the full zod package?

Use zod/mini when bundle size matters — client code, edge functions, a shipped widget — and 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(). It is more verbose, but with no method chain, unused code tree-shakes away. Both builds share the same core, so runtime behavior is identical.

import * as z from "zod/mini";

const User = z.object({
  email: z.email(),
  name: z.optional(z.string()),
});
Enter fullscreen mode Exit fullscreen mode
Concern zod (full) zod/mini
API style Chained methods (.optional()) Functional wrappers (z.optional())
Bundle size Larger Smaller, tree-shakeable
Ergonomics Fluent, readable More verbose
Same validators Yes Yes (same core)
Best for Servers, general code Edge/client, size-critical bundles

How do I 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. During the transition Zod published the new version under the zod/v4 import path (in zod@3.25) so you could adopt it file by file before zod@4.0; the legacy API stays reachable at zod/v3. My process: upgrade the package, run the type-checker and tests, then clear deprecation warnings in waves — rename string-format calls, collapse error options into error, and switch .strict()/.passthrough() to the new object functions. z.infer, .parse(), and .safeParse() did not change, so most schemas kept working untouched.

FAQ

Q: Is z.string().email() removed in Zod 4?
A: No. It still works but is deprecated and warns. 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 — a string for a fixed message or a function that receives the issue for conditional messages.

Q: How do I 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 for 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.


Originally published on devya.dev. Also on eng-ahmed.com. Built by Devya Solutions.

Top comments (1)

Collapse
 
ggle_in profile image
HARD IN SOFT OUT

Nothing prepares you for the moment you run npm update on a Friday afternoon and suddenly your entire validation layer is speaking a different language. I have been there—watching 47 test failures scroll past while the build pipeline turns red like a stoplight that personally hates me. Zod 4 is great, but the first migration is always a trust exercise between you and the package manager.

This is exactly the kind of migration guide I wish I had when I upgraded. The fact that you mapped the four API changes that actually hit real code—not just the theoretical ones—saves everyone hours of reading release notes and guessing. The error parameter consolidation is the one that would have tripped me the most, and you explained it clearly with the function-based conditional messages example. That is the kind of detail that separates a "I updated the package" from a "I actually understand what changed" migration.

One thing I would add for teams doing this at scale: consider using a type alias or a wrapper module during the transition. Something like import { z as zodV4 } from "zod/v4" lets you migrate file by file without committing to the new API everywhere at once. It adds a tiny bit of overhead, but it also means you are not shipping half-migrated schemas while you figure out the new error formatting.

Also, since you mentioned zod/mini for bundle size, have you measured the actual difference in a real project? I am curious whether the tree-shaking gains are noticeable in practice or mostly theoretical. My gut says it matters more for client-side widgets than for backend services, but I would love to know if you have real numbers.

Anyway, this is going straight into my "read before updating" folder. Thanks for writing the guide that the official docs should have included.