Zod v4 shipped. If you're on v3, your code still works — but you're leaving meaningful performance gains on the table, and some patterns you relied on are either deprecated or gone.
Here's what actually changed, what breaks, and how to migrate.
Why the Version Bump
Zod v4 is a near-complete rewrite focused on three things:
- Parse performance — v4 is 2-10x faster on complex schemas
- Bundle size — core is ~40% smaller
- Error handling — more predictable, more structured
The author (Colin McDonnell) described v3 as "correct but slow." v4 is the fix.
What Breaks
.email() is Stricter
v3 accepted a lot of technically-invalid email strings. v4 uses a stricter RFC 5321 validator.
// v3: passes
z.string().email().parse("user@localhost")
// v4: throws
z.string().email().parse("user@localhost")
// ZodError: Invalid email address
If you have test fixtures with bare local-domain emails, update them.
.transform() Type Inference Changed
In v3, chaining .transform() after .refine() required explicit generics in some cases. v4 infers correctly without them — but if you had explicit generics that were technically wrong, TypeScript will now catch it:
// v3: this compiled (incorrectly)
const schema = z.string()
.refine(s => s.length > 0)
.transform((s): number => parseInt(s));
// v4: TypeScript may flag this if parseInt can return NaN
// Correct form:
const schema = z.string()
.refine(s => s.length > 0 && !isNaN(parseInt(s)))
.transform(s => parseInt(s));
ZodError.format() Output Structure
The .format() method on ZodError has a new output shape. If you're consuming formatted errors in a frontend (form validation, API error responses), audit your error rendering:
// v3 format() output:
{ _errors: [], fieldName: { _errors: ["Required"] } }
// v4 format() output:
{ issues: [{ path: ["fieldName"], message: "Required" }] }
If you're using .format() to drive form field errors, update the consumer.
Discriminated Union Behavior
z.discriminatedUnion() in v4 requires the discriminant to be a z.literal() — it no longer accepts z.enum() as the discriminant type. This was technically an undocumented behavior in v3.
// v3: worked (accidentally)
z.discriminatedUnion("type", [
z.object({ type: z.enum(["a"]), value: z.string() }),
z.object({ type: z.enum(["b"]), value: z.number() }),
])
// v4: use z.literal()
z.discriminatedUnion("type", [
z.object({ type: z.literal("a"), value: z.string() }),
z.object({ type: z.literal("b"), value: z.number() }),
])
What's New and Worth Using
z.pipe()
The new z.pipe() utility lets you chain schemas cleanly without .transform() nesting:
const CoercedDate = z.pipe(
z.string(),
z.transform(s => new Date(s)),
z.instanceof(Date).refine(d => !isNaN(d.getTime()), "Invalid date")
);
Much cleaner than the v3 equivalent.
z.json()
Native JSON schema with round-trip validation:
const JsonSchema = z.json();
JsonSchema.parse('{"key": "value"}'); // returns parsed object
JsonSchema.parse('{invalid}'); // throws
Useful for validating stringified JSON payloads (webhook bodies, stored configs).
Faster Error Messages
v4 lazy-evaluates error messages by default. If you're doing high-volume validation (request parsing, batch jobs), this is a measurable win — error messages are only formatted when accessed.
z.interface() (New)
Experimental in v4: a more ergonomic way to define object schemas with better TS inference for deeply nested types:
const UserSchema = z.interface({
id: z.string().uuid(),
profile: z.interface({
name: z.string(),
avatar: z.string().url().optional(),
}),
});
This is still marked experimental but the inference is noticeably better than z.object() for 3+ levels of nesting.
The Migration Script
For most codebases, the migration is surgical. Run this to find the patterns that need attention:
# Find .email() usages (check for localhost/test domains in fixtures)
grep -r '\.email()' src/ --include="*.ts"
# Find .format() usages (update error consumers)
grep -r 'ZodError\|\.format()' src/ --include="*.ts"
# Find discriminatedUnion with enum (convert to literal)
grep -r 'discriminatedUnion' src/ --include="*.ts"
Then update the package:
npm install zod@^4.0.0
TypeScript will surface remaining issues immediately. Expect 30-60 minutes for a medium-sized codebase.
Should You Migrate Now?
Yes, if:
- You're doing server-side validation at high request volume (the performance gains are real)
- You're building a new project (start on v4)
- You're using
.safeParse()in hot paths
Wait, if:
- You have a working v3 codebase with complex error consumers (audit
.format()usage first) - You're on a shared library where downstream consumers pin to v3 types
The coexistence story is good — v3 and v4 can run in the same monorepo without conflict.
v4 and Drizzle/tRPC
Both Drizzle and tRPC have updated their Zod adapters for v4. If you're using drizzle-zod or @trpc/server, update those packages alongside Zod:
npm install zod@^4.0.0 drizzle-zod@latest @trpc/server@latest
The inferred types are tighter in v4 — you may see TypeScript complaints in your route definitions that were previously silent bugs.
Using Zod for API validation in your Claude agent? The AI SaaS Starter Kit ships with typed request/response schemas using Zod v4, Drizzle ORM, and Claude prompt caching — ready to deploy in a weekend.
Top comments (0)