Complete migration guide from Zod v3 to v4 covering all breaking changes and upgrade patterns
Provides a comprehensive migration guide from Zod v3 to v4, covering all breaking changes like string format methods becoming top-level functions, error parameter unification, and `.merge()` to `.extend()` replacements. Use this when upgrading Zod packages or encountering deprecated v3 patterns to ensure smooth migration with automated scripts and manual fix patterns.
/plugin marketplace add djankies/claude-configs/plugin install zod-4@claude-configsThis skill inherits all available tools. When active, it can use any tool Claude has access to.
Comprehensive guide for migrating existing Zod v3 codebases to v4, covering all breaking changes, migration patterns, and testing strategies.
Zod v4 introduced major performance improvements and API refinements:
Breaking changes are intentional improvements that require code updates.
Impact: Affects ~90% of Zod users using email/uuid/url validation
Before (v3):
const emailSchema = z.string().email();
const uuidSchema = z.string().uuid();
const datetimeSchema = z.string().datetime();
const urlSchema = z.string().url();
const ipSchema = z.string().ipv4();
const jwtSchema = z.string().jwt();
After (v4):
const emailSchema = z.email();
const uuidSchema = z.uuid();
const datetimeSchema = z.iso.datetime();
const urlSchema = z.url();
const ipSchema = z.ipv4();
const jwtSchema = z.jwt();
Migration script:
find ./src -name "*.ts" -o -name "*.tsx" | xargs sed -i '' \
-e 's/z\.string()\.email()/z.email()/g' \
-e 's/z\.string()\.uuid()/z.uuid()/g' \
-e 's/z\.string()\.datetime()/z.iso.datetime()/g' \
-e 's/z\.string()\.url()/z.url()/g' \
-e 's/z\.string()\.ipv4()/z.ipv4()/g' \
-e 's/z\.string()\.ipv6()/z.ipv6()/g' \
-e 's/z\.string()\.jwt()/z.jwt()/g' \
-e 's/z\.string()\.base64()/z.base64()/g'
error ParameterImpact: Affects error handling and user-facing validation messages
Before (v3):
z.string({ message: "Required field" });
z.string({ invalid_type_error: "Must be a string" });
z.string({ required_error: "This field is required" });
z.object({}, { errorMap: customErrorMap });
After (v4):
z.string({ error: "Required field" });
z.string({ error: "Must be a string" });
z.string({ error: "This field is required" });
z.object({}, { error: customErrorMap });
Migration pattern:
All error-related parameters now use single error field that accepts strings or error map functions.
Impact: Affects schema composition patterns
Before (v3):
const baseSchema = z.object({ id: z.string() });
const extendedSchema = baseSchema.merge(
z.object({ name: z.string() })
);
After (v4):
const baseSchema = z.object({ id: z.string() });
const extendedSchema = baseSchema.extend({
name: z.string()
});
Migration script:
find ./src -name "*.ts" | xargs sed -i '' \
-e 's/\.merge(z\.object(\([^)]*\)))/\.extend(\1)/g'
Impact: Custom validation logic and error messages
Before (v3):
z.string().refine((val) => val.length > 5, {
message: "Too short"
});
After (v4):
z.string().refine((val) => val.length > 5, {
error: "Too short"
});
Error customization in refinements also uses unified error parameter.
Not a breaking change, but highly recommended:
Before (v3 pattern):
const schema = z.string();
const result = schema.parse(input.trim().toLowerCase());
After (v4 recommended):
const schema = z.string().trim().toLowerCase();
const result = schema.parse(input);
Benefits:
npm install zod@^4.0.0
Or with specific version:
npm install zod@4.0.0
Use validation skill to identify deprecated patterns:
/review zod-compatibility
Or manually scan:
grep -r "z\.string()\.email(" ./src
grep -r "z\.string()\.uuid(" ./src
grep -r "\.merge(" ./src
grep -r "message:" ./src | grep -v "error:"
Run migration scripts:
./migrate-string-formats.sh
./migrate-error-params.sh
./migrate-merge-to-extend.sh
Some patterns require manual review:
Complex error maps:
const customErrorMap: ZodErrorMap = (issue, ctx) => {
if (issue.code === z.ZodIssueCode.invalid_type) {
return { message: "Invalid type!" };
}
return { message: ctx.defaultError };
};
z.string({ errorMap: customErrorMap });
Migration:
const customErrorMap: ZodErrorMap = (issue, ctx) => {
if (issue.code === z.ZodIssueCode.invalid_type) {
return { message: "Invalid type!" };
}
return { message: ctx.defaultError };
};
z.string({ error: customErrorMap });
Nested schema merges:
const a = z.object({ x: z.string() });
const b = z.object({ y: z.number() });
const c = a.merge(b);
Migration:
const a = z.object({ x: z.string() });
const b = z.object({ y: z.number() });
const c = a.extend({ y: z.number() });
Identify manual string operations and migrate to built-in methods:
Before:
const emailSchema = z.email();
const processEmail = (input: string) => {
const trimmed = input.trim().toLowerCase();
return emailSchema.parse(trimmed);
};
After:
const emailSchema = z.email().trim().toLowerCase();
const processEmail = (input: string) => {
return emailSchema.parse(input);
};
Comprehensive test suite after migration:
npm test
Check for:
Update code comments and docs referencing Zod APIs:
Problem:
const emailSchema = z.string().email();
type Email = z.infer<typeof emailSchema>;
After migration:
const emailSchema = z.email();
type Email = z.infer<typeof emailSchema>;
Solution: Type inference still works, but type is now more specific to email strings.
Problem: Error map using old parameter names
Solution: Update error map to use unified error parameter and ensure function signature matches ZodErrorMap type.
Problem: Nested merges don't translate directly to extend
Solution: Use multiple extend calls or restructure schema:
const result = base.extend(ext1.shape).extend(ext2.shape);
Problem: v4 error messages may differ from v3
Solution: Update test assertions to match new error format or use error codes instead of messages:
expect(result.error.issues[0].code).toBe(z.ZodIssueCode.invalid_type);
Test schema validation logic:
import { z } from 'zod';
describe('User schema validation', () => {
const userSchema = z.object({
email: z.email().trim().toLowerCase(),
username: z.string().trim().min(3)
});
it('validates correct user data', () => {
const result = userSchema.safeParse({
email: ' USER@EXAMPLE.COM ',
username: ' john '
});
expect(result.success).toBe(true);
if (result.success) {
expect(result.data.email).toBe('user@example.com');
expect(result.data.username).toBe('john');
}
});
it('rejects invalid email', () => {
const result = userSchema.safeParse({
email: 'not-an-email',
username: 'john'
});
expect(result.success).toBe(false);
});
});
Test form validation with transformed data:
const formSchema = z.object({
email: z.email().trim().toLowerCase(),
password: z.string().min(8)
});
const handleSubmit = async (formData: FormData) => {
const result = formSchema.safeParse({
email: formData.get('email'),
password: formData.get('password')
});
if (!result.success) {
return { errors: result.error.flatten() };
}
await createUser(result.data);
};
Verify type inference works correctly:
const schema = z.email().trim();
type Email = z.infer<typeof schema>;
const email: Email = 'test@example.com';
error parameter.merge() with .extend()After migration, expect:
Monitor performance improvements:
npm run build -- --stats
Compare bundle size before/after migration.