I made Claude Code suffer through migrating a production monorepo with 131 packages to Zod v4, then made it document everything.
Consider this your cheat sheet—all the breaking changes, gotchas, and fixes you need, battle-tested on mission-critical code.
// Before
"zod": "^3.23.8"
// After
"zod": "^4.0.0"Third-party libraries: Upgrade zod-validation-error to v5.0.0+ for compatibility.
String format validators moved from methods to top-level functions.
// Before
z.string().email()
z.string().uuid()
z.string().url()
// After
z.email()
z.uuid()
z.url()z.record() now requires two arguments (key schema, value schema).
// Before
z.record(z.string())
// After
z.record(z.string(), z.string())For enum-keyed records with optional keys, use z.partialRecord():
// Before
const MyEnum = z.enum(['key1', 'key2', 'key3'])
z.record(MyEnum, z.string())
// After
z.partialRecord(MyEnum, z.string())// Before
if (error instanceof ZodError) {
console.log(error.errors)
}
// After
if (error instanceof ZodError) {
console.log(error.issues)
}The second parameter to .parse() changed from custom data to parse options.
// Before - second param was custom data
const meta = { userId: 123 }
const result = Counter.parse({ value: 0 }, meta)
// After - separate custom data from parse result
const result = {
data: Counter.parse({ value: 0 }),
meta,
}Zod v4 enforces RFC 4122 compliance. UUIDs must have proper version and variant bits.
'00000000-0000-0000-0000-000000000001' // ❌ Invalid
'11111111-1111-1111-1111-111111111111' // ❌ Invalid
'22222222-2222-2222-2222-222222222222' // ❌ Invalid'00000000-0000-4000-8000-000000000001' // ✅ Valid
'11111111-1111-4111-8111-111111111111' // ✅ Valid
'22222222-2222-4222-8222-222222222222' // ✅ ValidPattern:
- 3rd group (version): Must start with 1-8
- 4th group (variant): Must start with 8, 9, a, or b
Note: If you generate UUIDs on your own (not recommended outside of test code, as it's likely not fully compliant with RFC 4122), you must ensure compliance with the specification's version and variant bit requirements.
// Example: Converting arbitrary bytes to UUID format
export function toUuidFormat(buf: Buffer) {
const h = buf.toString('hex').toLowerCase()
// Set version 4 bits (13th hex digit)
const versionChar = '4'
// Set variant bits (17th hex digit: 10xxxxxx pattern → 8-b range)
const variantValue = parseInt(h.charAt(16), 16)
const rfc4122VariantChar = ((variantValue & 0x3) | 0x8).toString(16)
return [
h.slice(0, 8),
h.slice(8, 12),
versionChar + h.slice(13, 16), // Version 4
rfc4122VariantChar + h.slice(17, 20), // RFC 4122 variant
h.slice(20, 32),
].join('-')
}Zod v4 changed default error messages. Update test expectations:
// Before
expect(() => schema.parse(invalid)).toThrow('Required')
expect(() => schema.parse(invalid)).toThrow('Expected string, received number')
expect(() => schema.parse(invalid)).toThrow('Expected object, received string')
// After
expect(() => schema.parse(invalid)).toThrow('Invalid input: expected string, received undefined')
expect(() => schema.parse(invalid)).toThrow('Invalid input: expected string, received number')
expect(() => schema.parse(invalid)).toThrow('Invalid input: expected object, received string')For regex patterns in tests:
// Before
.toThrow(/"expected": "object".*"received": "undefined"/s)
.toThrow(/path.*myField.*message.*Required/s)
// After
.toThrow(/Invalid input.*expected record.*received undefined/s)
.toThrow(/path.*myField.*message.*Invalid input.*expected string.*received undefined/s)Some complex type patterns may break. Simplify by removing over-constrained types:
// Before - may cause type errors
type PreprocessFuncReturnType<S extends z.ZodRawShape> = ReturnType<typeof z.preprocess<z.ZodObject<S>>>
// After - rely on inference
function myPreprocess<S extends z.ZodRawShape>(shape: S) {
return z.preprocess((input, ctx) => {
// transformation logic
return input
}, z.object(shape))
}