Schema Validation with Zod

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

src/checkout/schema.ts: rules, type and messages in one place (excerpt)TypeScript
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:

scripts/try-schema.ts: an invalid checkout (excerpt)TypeScript
const bad = checkoutSchema.safeParse({
  name: 'A', email: 'ana@example', delivery: 'ship', address: '', postcode: '8840',
});
if (!bad.success) {
  console.log(z.flattenError(bad.error).fieldErrors);
Output
$ 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().