DEV Community

The Practical Developer
The Practical Developer

Posted on Originally published at practicaldevblog.blogspot.com

Zod: Stop Bad Data at the Door of Your Checkout

A customer fills in a checkout form and picks two ceramic mugs and a linen tote. Somewhere between the form and the server, the quantity turns into the string "2" instead of the number 2. TypeScript does not catch it, because TypeScript checks code before it runs, not data arriving while the program is live. The server adds the quantity to the total and the maths produces nonsense, or the order saves with a quantity nobody can fulfil. The bug is not in the form or in the maths. It is in the gap between what the code expects and what actually arrives. Zod closes that gap: describe the shape of the data once, and check every order against it.

What I would check before using it

My judgement is that Zod is worth adding the moment your code receives data it did not create: a checkout form, a webhook from a payment provider, a response from another team's API. At a boundary, types are only a promise, and Zod is the check that the promise holds. The first thing I would check is whether the project uses TypeScript. Zod infers a type from the schema, so the shape you validate and the type your code uses can never drift apart. In plain JavaScript it still works, but without that inference you lose half the reason to pick it.

The second check is the error output. A validator that says "invalid input" and nothing else is little help at 2 a.m. Zod's errors name the exact field, the path to it inside a nested object, and what was expected. The third check is size and setup: no dependencies, one install command, no build step or server to configure. The fourth check is the alternative. Hand-written if statements do the same job for one form, but they grow into a mess the third time you repeat them, and never give you a TypeScript type for free. Joi and Yup do similar work, but neither infers types as tightly as Zod does.

The verdict, in short: use Zod for data that crosses a boundary into TypeScript code, especially forms and API payloads. That flips if the project is plain JavaScript with no types at all, or if the data never leaves your own code, because then the schema is work with no payoff. It also flips if the data shape changes every week: a strict schema rejects the new shape until someone updates it, and that someone is you.

A shop checkout, checked end to end

A Zod schema is a small declaration of what a piece of data must look like: this field is an email, that field is a positive whole number, this list has at least one item. You write it once, in the same file as the code that handles the order, and you run it on every order that comes in. Valid data passes through unchanged, with its type now known to TypeScript. Invalid data is rejected with a list of exactly what was wrong, field by field, so the checkout can tell the customer what to fix instead of crashing or saving a broken order.

The worked example is the shop from the opening. An order has a customer email, a list of items with a SKU, a name, a quantity and a unit price, and a delivery postcode. The schema says what each of those must be: the email must look like an email, the quantity must be a positive whole number, the price must be a positive number, the list must hold at least one item, and the postcode must match a UK postcode pattern. Here is the schema and a valid order, run with Node.js and Zod 4.6.7:

const { z } = require('zod');

const orderSchema = z.object({
  customerEmail: z.string().email(),
  items: z.array(z.object({
    sku: z.string().min(1),
    name: z.string().min(1),
    quantity: z.number().int().positive(),
    unitPrice: z.number().positive(),
  })).min(1),
  deliveryPostcode: z.string().regex(
    /^[A-Z]{1,2}[0-9][0-9A-Z]?\s?[0-9][A-Z]{2}$/i,
    'must be a UK postcode like SW1A 1AA'
  ),
});

const order = {
  customerEmail: 'ayesha@example.com',
  items: [
    { sku: 'MUG-101', name: 'Ceramic mug', quantity: 2, unitPrice: 18.00 },
    { sku: 'TOTE-102', name: 'Linen tote', quantity: 1, unitPrice: 24.50 },
  ],
  deliveryPostcode: 'SW1A 1AA',
};

const parsed = orderSchema.parse(order);
const total = parsed.items.reduce((sum, item) => sum + item.quantity * item.unitPrice, 0);
console.log('Order accepted for ' + parsed.customerEmail);
console.log('Items: ' + parsed.items.length + ', total: $' + total.toFixed(2));
console.log('Deliver to: ' + parsed.deliveryPostcode);
Enter fullscreen mode Exit fullscreen mode

Running it gave this real output:

Order accepted for ayesha@example.com
Items: 2, total: $60.50
Deliver to: SW1A 1AA
Enter fullscreen mode Exit fullscreen mode

The total is right: two mugs at 18.00 is 36.00, plus one tote at 24.50, is 60.50. The parsed order is also a typed value now, so TypeScript knows parsed.items[0].quantity is a number, not a guess.

Now the part the tidy tutorials skip: something goes wrong. A web form sends every value as a string, so the same order arrives with the quantity as "2" and the price as "18.00", both in quotes. The schema expects numbers, and Zod refuses the whole order. The real output, one line per problem, is:

Order accepted: false
items.0.quantity: Invalid input: expected number, received string
items.0.unitPrice: Invalid input: expected number, received string
items.1.quantity: Invalid input: expected number, received string
items.1.unitPrice: Invalid input: expected number, received string
Enter fullscreen mode Exit fullscreen mode

Four problems, all the same cause, and each one names the exact field. Nothing was saved, nothing was charged, and the checkout knows precisely what to tell the customer. The fix is small: tell the schema to convert the strings first. Zod's z.coerce.number() turns "2" into 2 and "18.00" into 18 before the rest of the checks run, so the same form payload passes. Changing z.number() to z.coerce.number() on the quantity and the unit price, and running the same payload again, gave:

Order accepted for ayesha@example.com
Items: 2, total: $60.50
Deliver to: SW1A 1AA
Enter fullscreen mode Exit fullscreen mode

The lesson is one I keep relearning: data from a form is text until proven otherwise. A schema that expects numbers will reject a form's strings every time, and the fix belongs in the schema, where the conversion is visible, not in a quiet Number() call buried in the handler.

Common mistakes and how to fix them

1. Calling .parse() without a try/catch. .parse() throws a ZodError when the data is invalid, and an uncaught throw inside a request handler crashes the request or the process. The fix is .safeParse(), which returns an object with a success flag: show the issues to the customer when it is false, carry on when it is true.

2. Expecting form values to arrive typed. A form sends "2", not 2, and the schema rejects it with Invalid input: expected number, received string, exactly as the worked example shows. The fix is z.coerce.number() on the numeric fields, which converts the string before checking it.

3. Assuming extra fields are rejected. Zod strips unknown keys by default, so an order with giftWrap: true passes validation and the parsed result quietly drops the field. If extra data should be an error, add .strict() to the object schema so unknown keys fail instead of vanishing.

4. Forgetting .positive() on a quantity. .int() alone allows 0, so an order for zero mugs passes validation and reaches the warehouse. The fix is .int().positive(), which rejects 0 with Too small: expected number to be >0.

Where this leaves you

Zod is a small idea with a large payoff: describe the shape of incoming data once, and let the library say no before bad data reaches your database or your maths. It is worth the setup wherever data crosses into your code from outside, and it earns its keep fastest on forms and API payloads, where the types lie and the strings pretend to be numbers. The next step is to take one form or one endpoint in your own project, write a schema for it, and run a bad payload through. When the error names the field that is wrong, you will know whether your checkout deserves a gatekeeper or just a hope.

Further reading

Originally published at https://practicaldevblog.blogspot.com/2026/11/zod-stop-bad-data-at-door-of-your.html

Top comments (0)