Review Zod schemas for correctness, performance, and v4 best practices
Reviews Zod schemas for v4 compatibility, performance, and security best practices. Triggers when reviewing schema definitions or validation logic.
/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 code review checklist for Zod schemas ensuring v4 compliance, type safety, performance, and maintainability.
String format methods:
z.string().email() // ❌ Deprecated
z.string().uuid() // ❌ Deprecated
z.string().datetime() // ❌ Deprecated
z.email() // ✅ Correct v4
z.uuid() // ✅ Correct v4
z.iso.datetime() // ✅ Correct v4
Error customization:
z.string({ message: "Required" }) // ❌ Deprecated
z.string({ invalid_type_error: "Wrong type" }) // ❌ Deprecated
z.string({ required_error: "Missing" }) // ❌ Deprecated
z.string({ error: "Required" }) // ✅ Correct v4
Schema composition:
schemaA.merge(schemaB) // ❌ Deprecated
schemaA.extend(schemaB.shape) // ✅ Correct v4
User input should always be trimmed:
z.string() // ❌ Missing trim
z.string().trim() // ✅ Correct
Email and username normalization:
z.email() // ❌ Missing normalization
z.email().trim().toLowerCase() // ✅ Correct
Code/identifier normalization:
z.string() // ❌ Missing normalization
z.string().trim().toUpperCase() // ✅ Correct for codes
Manual vs. declarative transformations:
const trimmed = input.trim();
z.string().parse(trimmed); // ❌ Manual transformation
z.string().trim().parse(input); // ✅ Declarative
Check proper type extraction:
type User = typeof userSchema; // ❌ Wrong
type User = z.infer<typeof userSchema>; // ✅ Correct
Transform pipelines:
const schema = z.string().transform(s => parseInt(s));
type Input = z.input<typeof schema>; // string
type Output = z.output<typeof schema>; // number
Branded types for nominal typing:
const userId = z.string(); // ❌ Not branded
const userId = z.string().brand<'UserId'>(); // ✅ Branded
Anti-pattern:
try {
const data = schema.parse(input);
} catch (error) {
console.error(error);
}
Best practice:
const result = schema.safeParse(input);
if (!result.success) {
console.error(result.error.flatten());
return;
}
const data = result.data;
Check for user-friendly errors:
z.string() // ❌ Default error
z.string({ error: "Name is required" }) // ✅ Custom error
Complex error maps:
const userSchema = z.object({
email: z.email({ error: "Please enter a valid email address" }),
age: z.number({ error: "Age must be a number" }).min(18, {
error: "Must be 18 or older"
})
});
Anti-pattern:
function validateUser(data: unknown) {
const schema = z.object({ email: z.email() });
return schema.parse(data);
}
Best practice:
const userSchema = z.object({ email: z.email() });
function validateUser(data: unknown) {
return userSchema.parse(data);
}
Anti-pattern:
items.forEach(item => schema.parse(item));
Best practice:
const arraySchema = z.array(schema);
arraySchema.parse(items);
Zod v4 bulk validation is 7x faster than item-by-item parsing.
Understand cost implications:
z.object({}).strict() // Strips unknown keys (cost)
z.object({}).passthrough() // Keeps unknown keys (faster)
Use .passthrough() when you don't need to strip unknown properties.
Check for correct schema selection:
z.string().refine(s => s === 'true' || s === 'false') // ❌ Complex
z.stringbool() // ✅ Built-in v4 type
Union vs. Discriminated Union:
z.union([typeA, typeB]) // ❌ Slower parsing
z.discriminatedUnion('type', [ // ✅ Faster
z.object({ type: z.literal('a'), ...typeA }),
z.object({ type: z.literal('b'), ...typeB })
])
Optional vs. Nullable vs. Nullish:
z.string().optional() // string | undefined
z.string().nullable() // string | null
z.string().nullish() // string | null | undefined
Use the most specific type for your use case.
Extend for adding fields:
const base = z.object({ id: z.string() });
const extended = base.extend({
name: z.string(),
email: z.email()
});
Pick/Omit for subsets:
const userSchema = z.object({
id: z.string(),
email: z.email(),
password: z.string()
});
const publicUserSchema = userSchema.omit({ password: true });
const loginSchema = userSchema.pick({ email: true, password: true });
Check refinement usage:
z.string().refine(val => val.length > 5) // ❌ Missing error
z.string().refine(
val => val.length > 5,
{ error: "Must be at least 6 characters" }
) // ✅ With error
Async refinements:
z.string().email().refine(
async (email) => {
return await checkEmailUnique(email);
},
{ error: "Email already exists" }
)
Check transformation order:
z.string().transform(s => s.toUpperCase()).trim() // ❌ Wrong order
z.string().trim().transform(s => s.toUpperCase()) // ✅ Correct order
Transformations run after validation, built-in methods run first.
Type-safe transformations:
const schema = z.string().transform(s => parseInt(s));
type Output = z.output<typeof schema>; // number
Check entry point validation:
function handleFormSubmit(formData: FormData) {
const data = {
email: formData.get('email'),
password: formData.get('password')
};
const result = loginSchema.safeParse(data);
if (!result.success) {
return { errors: result.error };
}
await login(result.data);
}
Avoid validation bypass:
function processUser(user: User) {
await saveToDb(user);
}
processUser({ email: 'test@example.com' }); // ❌ No validation
Always validate at boundaries:
function processUser(data: unknown) {
const result = userSchema.safeParse(data);
if (!result.success) throw new Error('Invalid user');
await saveToDb(result.data);
}
Zod validates type/shape, NOT malicious content:
z.string() // Allows: "'; DROP TABLE users; --"
Combine with sanitization:
import { sanitize } from 'sanitization-library';
const schema = z.string().transform(s => sanitize(s));
Test valid inputs:
describe('userSchema', () => {
it('accepts valid user', () => {
const result = userSchema.safeParse({
email: 'test@example.com',
username: 'john'
});
expect(result.success).toBe(true);
});
});
Test invalid inputs:
it('rejects invalid email', () => {
const result = userSchema.safeParse({
email: 'not-an-email',
username: 'john'
});
expect(result.success).toBe(false);
if (!result.success) {
expect(result.error.issues[0].path).toEqual(['email']);
}
});
Test transformations:
it('trims and lowercases email', () => {
const schema = z.email().trim().toLowerCase();
const result = schema.safeParse(' TEST@EXAMPLE.COM ');
expect(result.success).toBe(true);
if (result.success) {
expect(result.data).toBe('test@example.com');
}
});
Run validation skill:
/review zod-compatibility
Checks for:
Review each schema for:
Ensure tests cover:
Problem: Whitespace causes validation failures
Fix:
z.string().min(1) // ❌ " " passes
z.string().trim().min(1) // ✅ " " fails
Problem: Exceptions for invalid data
Fix:
try { schema.parse(data) } // ❌ Exception-based
const result = schema.safeParse(data) // ✅ Result-based
Problem: Schema recreated on every call
Fix:
function validate(data: unknown) {
const schema = z.object({...}); // ❌ Recreated
return schema.parse(data);
}
const schema = z.object({...}); // ✅ Module-level
function validate(data: unknown) {
return schema.parse(data);
}
Problem: Using schema type directly
Fix:
type User = typeof userSchema; // ❌ ZodObject type
type User = z.infer<typeof userSchema>; // ✅ Inferred type
This skill integrates with the review plugin via review: true frontmatter.
Invoke with:
/review zod
Or explicitly:
/review zod-schemas
Cross-Plugin References: