Skip to content

Instantly share code, notes, and snippets.

@imaman
Created November 20, 2025 18:07
Show Gist options
  • Select an option

  • Save imaman/a62d1c7bab770a3b49fe3be10a66f48a to your computer and use it in GitHub Desktop.

Select an option

Save imaman/a62d1c7bab770a3b49fe3be10a66f48a to your computer and use it in GitHub Desktop.
Zod v4 Migration Guide: Breaking Changes, UUID Validation, String Format Methods, Record Schema Updates, and Error Message Changes

Zod v4 Migration Guide

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.

1. Package Updates

// Before
"zod": "^3.23.8"

// After
"zod": "^4.0.0"

Third-party libraries: Upgrade zod-validation-error to v5.0.0+ for compatibility.

2. String Format Methods

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

3. Record Schema

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

4. ZodError Structure

// Before
if (error instanceof ZodError) {
  console.log(error.errors)
}

// After
if (error instanceof ZodError) {
  console.log(error.issues)
}

5. Parse Method Signature

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,
}

6. UUID Validation

Zod v4 enforces RFC 4122 compliance. UUIDs must have proper version and variant bits.

Invalid UUIDs (will fail validation):

'00000000-0000-0000-0000-000000000001' // ❌ Invalid
'11111111-1111-1111-1111-111111111111' // ❌ Invalid
'22222222-2222-2222-2222-222222222222' // ❌ Invalid

Valid UUIDs (RFC 4122 compliant):

'00000000-0000-4000-8000-000000000001' // ✅ Valid
'11111111-1111-4111-8111-111111111111' // ✅ Valid
'22222222-2222-4222-8222-222222222222' // ✅ Valid

Pattern:

  • 3rd group (version): Must start with 1-8
  • 4th group (variant): Must start with 8, 9, a, or b

Fixing UUID Generation Functions

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('-')
}

7. Error Message Format Changes

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)

8. Type Inference Issues

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))
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment