patterntypescriptreactTip
Zod transforms and refinements — shaping and cross-validating data
Viewed 0 times
zod transformzod refinesuperRefinepipez.inputz.inferdata normalisationconditional validation
Problem
Raw input from forms or APIs often requires normalisation (trimming strings, converting types, setting defaults) before use. Mixing this transformation logic with business logic makes components harder to test and reason about.
Solution
Use .transform() to reshape data and .refine()/.superRefine() for conditional validation:
import { z } from 'zod';
// Transform: parse a string date into a Date object
const eventSchema = z.object({
title: z.string().trim().min(1), // trim whitespace automatically
startDate: z.string().datetime().transform((s) => new Date(s)),
endDate: z.string().datetime().transform((s) => new Date(s)),
maxAttendees: z.string().transform(Number).pipe(z.number().int().positive()),
tags: z.string().transform((s) => s.split(',').map((t) => t.trim())),
}).refine(
(data) => data.endDate > data.startDate,
{ message: 'End date must be after start date', path: ['endDate'] }
);
type Event = z.infer<typeof eventSchema>;
// endDate is Date, not string — transform changes the output type
// superRefine for multiple conditional errors
const passwordSchema = z.object({
password: z.string(),
confirmPassword: z.string(),
}).superRefine((data, ctx) => {
if (data.password !== data.confirmPassword) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: 'Passwords do not match',
path: ['confirmPassword'],
});
}
if (data.password.length < 8) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: 'Too short',
path: ['password'],
});
}
});
import { z } from 'zod';
// Transform: parse a string date into a Date object
const eventSchema = z.object({
title: z.string().trim().min(1), // trim whitespace automatically
startDate: z.string().datetime().transform((s) => new Date(s)),
endDate: z.string().datetime().transform((s) => new Date(s)),
maxAttendees: z.string().transform(Number).pipe(z.number().int().positive()),
tags: z.string().transform((s) => s.split(',').map((t) => t.trim())),
}).refine(
(data) => data.endDate > data.startDate,
{ message: 'End date must be after start date', path: ['endDate'] }
);
type Event = z.infer<typeof eventSchema>;
// endDate is Date, not string — transform changes the output type
// superRefine for multiple conditional errors
const passwordSchema = z.object({
password: z.string(),
confirmPassword: z.string(),
}).superRefine((data, ctx) => {
if (data.password !== data.confirmPassword) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: 'Passwords do not match',
path: ['confirmPassword'],
});
}
if (data.password.length < 8) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: 'Too short',
path: ['password'],
});
}
});
Why
.transform() changes the output type of the schema — the inferred TypeScript type after transform reflects the transformed shape. .refine() is for single conditions; .superRefine() allows adding multiple issues and has access to the Zod issue context.
Gotchas
- .transform() changes z.infer's output type but not the input type — z.input<typeof schema> gives the pre-transform shape
- .pipe() chains schemas: z.string().transform(Number).pipe(z.number()) validates as string, transforms to number, then validates the number
- .refine() short-circuits on first failure; .superRefine() allows collecting multiple errors in one pass
- Avoid heavy async transforms in form resolvers — async refinements work but add latency to form validation
Revisions (0)
No revisions yet.