Skip to content

APIs

Validate once, at the boundary

Why schema validation belongs in the HTTP layer, and how Zod turns a validation step into the single source of truth for a route's types.

3 min readUpdated 2 Oct 20264 sections

The cheapest bugs in an Express API are the ones that never needed to be possible. A handler that reads req.body.email and passes it straight to a database query has already accepted undefined, a number, an object and a very long string, each of which is a bug waiting for the right input.

The fix is not defensive coding scattered through the handler. It is one validation step at the edge of the application, after which everything downstream can be trusted.

The shape of the fix

A validation library that infers its own types removes the worst part of this work, which is keeping a hand-written TypeScript interface and a hand-written runtime check in agreement. Zod does that: define the schema once, and the type falls out of it.

schemas/order.ts
import { z } from "zod";

export const createOrderSchema = z.object({
  items: z
    .array(
      z.object({
        variantId: z.string().uuid(),
        quantity: z.number().int().min(1).max(20),
      })
    )
    .min(1),
  deliveryZone: z.enum(["inside-dhaka", "outside-dhaka", "suburban", "remote"]),
  couponCode: z.string().trim().min(3).max(32).optional(),
});
Condensed from the pattern used in the jewellery storefront API.

Two things in that schema matter more than the field list. min(1) on the array makes an empty cart a validation error rather than a zero-value order. max(20) on quantity stops a client from asking for a million units of something, which is a denial-of-service vector dressed up as a form field.

Parse, don't check

The important habit is calling parse and using the *result*, not calling safeParse and then continuing to read from req.body. The whole benefit comes from the type change. If you validate and then keep using the original object, you have added a runtime check without removing any of the uncertainty, and the next developer will not know which value is trustworthy.

routes/orders.ts
router.post("/orders", requireAuth, async (req, res) => {
  // Throws a ZodError that the error middleware turns into a 400.
  const payload = createOrderSchema.parse(req.body);

  const subtotal = await priceBasket(payload.items);
  const delivery = deliveryCost(payload.deliveryZone, subtotal);
  const discount = await resolveCoupon(payload.couponCode, subtotal);

  const order = await db.order.create({
    data: {
      userId: req.user.id,
      items: payload.items,
      subtotal,
      delivery,
      discount,
      status: "pending",
    },
  });

  res.status(201).json({ id: order.id, subtotal, delivery, discount });
});

Push the rules to where the data is

Validation is not only about incoming requests. Any value that is computed rather than supplied should be validated before it is trusted. Coupon percentages, delivery costs and stock counts are all attacker-influenced in the sense that a client chose the inputs that produced them.

lib/coupon.ts
import { z } from "zod";

const appliedCoupon = z.object({
  code: z.string(),
  percentOff: z.number().min(0).max(90),
  minSubtotal: z.number().min(0),
  expiresAt: z.date(),
});

export async function resolveCoupon(code: string | undefined, subtotal: number) {
  if (!code) return 0;

  const row = await db.coupon.findUnique({ where: { code } });
  if (!row) return 0;

  // Validate the stored row before trusting anything computed from it.
  const coupon = appliedCoupon.safeParse({
    code: row.code,
    percentOff: row.percentOff,
    minSubtotal: row.minSubtotal,
    expiresAt: row.expiresAt,
  });

  if (!coupon.success) return 0;
  if (subtotal < coupon.data.minSubtotal) return 0;
  if (coupon.data.expiresAt.getTime() < Date.now()) return 0;

  return Math.floor((subtotal * coupon.data.percentOff) / 100);
}

The max(90) there is not about coupons. It is about the fact that a percentage above 100 would produce a negative total, and a negative total is a fascinating thing to hand to a payment gateway.

What validation does not do

Validation checks shape. It does not check authorisation, and it is not a defence against injection on its own — that comes from parameterised queries, which Prisma and Mongoose both give you by default if you let them. A schema that validates role: z.enum(["customer"]) does not stop someone calling an admin route; it only stops them claiming a role in the payload.