Zod 44,027 (https://github.com/colinhacks/zod 44,027 ) (MIT) checks values against a schema at run time and derives the TypeScript type from it, so rules and types cannot drift apart. Zod 4 made string formats top-level (z.email()), merged the old message and required_error options into one error option, and added z.flattenError() and z.prettifyError():
export const checkoutSchema = z
.object({
name: z.string().trim().min(2, { error: 'Enter your full name' }),
email: z.email({ error: 'Enter an email like ana@example.com' }),
delivery: z.enum(['ship', 'pickup']),
address: z.string().trim(),
postcode: z.string().trim(),
})
.superRefine((v, ctx) => {
if (v.delivery === 'pickup') return; // the shop needs no address
if (v.address.length < 5)
ctx.addIssue({ code: 'custom', path: ['address'], message: 'Enter a street address' });
if (!/^\d{5}$/.test(v.postcode))
ctx.addIssue({ code: 'custom', path: ['postcode'], message: 'A postcode has 5 digits' });
});
export type CheckoutForm = z.infer<typeof checkoutSchema>;A cross-field rule goes in superRefine, whose path files each issue under the right field. You can try the schema in Node with tsx (https://github.com/privatenumber/tsx 12,162 ) before any screen exists:
const bad = checkoutSchema.safeParse({
name: 'A', email: 'ana@example', delivery: 'ship', address: '', postcode: '8840',
});
if (!bad.success) {
console.log(z.flattenError(bad.error).fieldErrors);$ npx tsx scripts/try-schema.ts
...
{
name: [ 'Enter your full name' ],
email: [ 'Enter an email like ana@example.com' ],
address: [ 'Enter a street address' ],
postcode: [ 'A postcode has 5 digits' ]
}safeParse never throws: it returns the cleaned data (a valid ' Ana Lim ' came back as 'Ana Lim') or every issue at once. The refinement ran although name and email had failed, because Zod skips refinements only after a non-continuable issue such as a wrong type. TextInput yields strings, so numbers need z.coerce.number().