Zod validation in Node.js: validate API input safely

Zod validation for a Node.js API: why TypeScript types don't protect against bad input, and how a Zod schema blocks broken orders with field-level errors.

Zod validation means describing valid data with a schema, then letting Zod check everything that is sent in and report errors field by field. Bad data is stopped at the door. Want an API that doesn't crash because of one broken order? Let's build one.

The website will soon send orders to the server. Who checks them?

In the previous post, I cleaned up the server.ts file of the order system of Mộc (a small Vietnamese coffee shop that sells online) following the notes of Khoa, the code reviewer. The shop and Khoa are made-up characters in this series. The code became easier to read, and the server now receives its list of orders through a parameter instead of reading a file itself. Right then, the shop owner messaged: the shop's website will soon send orders straight to this server with a POST request, instead of waiting for someone to export a JSON file every morning.

I already knew how data from the file goes wrong: the old point-of-sale machine writes prices as strings like "35.000". Nobody knows yet how data from the website will go wrong. It may be missing fields, send the quantity as a word, or send an empty list of items. And it comes from outside the server, where I have no control.

So you can follow along without opening the previous post, I'm rebuilding the parts we need. Create a folder moc-orders with this structure:

Folder structure
moc-orders/
  package.json
  src/orders.ts
  src/sample-orders.ts
  src/order-schema.ts
  src/order-server.ts
  src/order-schema.test.ts
  naive/order-server.ts
  naive/try.ts
  try-zod.ts

package.json needs Node.js 22.18 or later, because from that version Node runs .ts files directly:

package.json
{
  "name": "moc-orders",
  "private": true,
  "type": "module",
  "dependencies": { "zod": "4.6.7" },
  "devDependencies": { "vitest": "5.1.0" }
}

The file src/orders.ts is what's left from the earlier posts. It describes an order, reads a price (including the string price "35.000" from the old machine) and computes totals. I left out the parts this post doesn't use:

src/orders.ts
export type Customer = { name: string; phone: string };
export type OrderItem = { name: string; price: number; quantity: number };
export type Order = {
  id: number;
  createdAt: string;
  source: "web" | "old-pos";
  // Orders from the old point-of-sale machine (source "old-pos") sold at the counter may have no customer.
  customer?: Customer;
  items: OrderItem[];
};

// Mộc's menu has nothing under 1,000 đồng. A smaller price is almost certainly broken data,
// e.g. "35.000" losing its quotes in JSON and becoming the number 35.
export const MIN_PRICE = 1000;

// "35.000" → 35000. Unusual formats, or a price that isn't a whole number of at least MIN_PRICE, throw instead of guessing.
export function parsePrice(raw: number | string): number {
  const price = typeof raw === "number" ? raw : parsePriceText(raw);
  if (!Number.isInteger(price) || price < MIN_PRICE) throw new Error(`Invalid price: ${quoteIfText(raw)}`);
  return price;
}

function parsePriceText(raw: string): number {
  const text = raw.trim();
  // Dots between thousands. Matches: "35.000", "1.250.000". Does not match: "35,000", "35.00", "3500.000".
  if (/^\d{1,3}(\.\d{3})+$/.test(text)) return Number(text.replaceAll(".", ""));
  if (/^\d+$/.test(text)) return Number(text);
  throw new Error(`Invalid price: ${quoteIfText(raw)}`);
}

// Text in quotes, numbers as they are, so the error message tells "35" apart from 35.
const quoteIfText = (raw: number | string) => (typeof raw === "string" ? `"${raw}"` : String(raw));

export function customerLabel(order: Order): string {
  if (!order.customer) return "WALK-IN CUSTOMER";
  return order.customer.name.toUpperCase();
}

export function orderTotal(order: Order): number {
  return order.items.reduce((sum, item) => sum + item.price * item.quantity, 0);
}

// The list for the management page: one line per order with the id, the customer name (in capitals) and the total.
export function listOrders(orders: Order[]) {
  return orders.map((order) => ({
    id: order.id,
    customer: customerLabel(order),
    total: orderTotal(order),
  }));
}

And src/sample-orders.ts holds two ready-made orders to try the server with, one from the website and one at the counter with no customer information:

src/sample-orders.ts
import type { Order } from "./orders.ts";

// Two ready-made orders to try the server with: one from the website, one at the counter with no customer information.
export const SAMPLE_ORDERS: Order[] = [
  {
    id: 1041,
    createdAt: "2026-11-03T08:12:00+07:00",
    source: "web",
    customer: { name: "Nguyễn An", phone: "0901000001" },
    items: [
      { name: "Iced milk coffee", price: 29000, quantity: 2 },
      { name: "Egg banh mi", price: 25000, quantity: 1 },
    ],
  },
  {
    id: 1043,
    createdAt: "2026-11-03T10:05:00+07:00",
    source: "old-pos",
    items: [{ name: "Black coffee", price: 25000, quantity: 1 }],
  },
];

What happens if you trust the data completely?

The first thing I thought of, and what most examples online do, is to read the request body, JSON.parse it and push it onto the list. Below is the first version of the server. It has two routes: POST /orders takes a new order, and GET /orders returns the list as before:

naive/order-server.ts
import { createServer, type Server, type ServerResponse } from "node:http";

import { listOrders, type Order } from "../src/orders.ts";

const JSON_HEADERS = { "content-type": "application/json; charset=utf-8" };
const TEXT_HEADERS = { "content-type": "text/plain; charset=utf-8" };

// First version of POST /orders: trusts the data it is sent completely.
export function createOrderServer(orders: Order[]): Server {
  return createServer(async (request, response) => {
    if (request.url === "/orders" && request.method === "POST") return receiveOrder(request, response, orders);
    if (request.url === "/orders") return sendOrderList(response, orders);
    response.writeHead(404).end();
  });
}

async function receiveOrder(request: AsyncIterable<Buffer>, response: ServerResponse, orders: Order[]): Promise<void> {
  let body = "";
  for await (const chunk of request) body += chunk;
  const data = JSON.parse(body);
  const order = { id: Math.max(...orders.map((o) => o.id)) + 1, createdAt: new Date().toISOString(), source: "web", ...data } as Order;
  orders.push(order);
  response.writeHead(201, JSON_HEADERS).end(JSON.stringify({ id: order.id }));
}

function sendOrderList(response: ServerResponse, orders: Order[]): void {
  try {
    const orderListJson = JSON.stringify(listOrders(orders), null, 2);
    response.writeHead(200, JSON_HEADERS).end(orderListJson);
  } catch (error) {
    console.error(error);
    response.writeHead(500, TEXT_HEADERS).end("Server error");
  }
}

Notice the line with as Order. I tell TypeScript "trust me, this is an order" about data I haven't checked at all. TypeScript believes it, and the code still compiles quietly. To see what really happens, I wrote a script that starts this server, sends three orders one by one, two of them broken, and reads the list back after each:

naive/try.ts
// Sends "website" orders one by one to the first version of the server, reading the list back after each one.
import type { AddressInfo } from "node:net";

import { SAMPLE_ORDERS } from "../src/sample-orders.ts";
import { createOrderServer } from "./order-server.ts";

const server = createOrderServer([...SAMPLE_ORDERS]);
await new Promise<void>((resolve) => server.listen(0, resolve));
const root = `http://localhost:${(server.address() as AddressInfo).port}/orders`;

const customer = { name: "Phạm Dung", phone: "0901000010" };
const ORDERS: [string, unknown][] = [
  ["valid order", { customer, items: [{ name: "Iced milk coffee", price: 29000, quantity: 2 }] }],
  ["quantity is a word", { customer, items: [{ name: "Iced milk coffee", price: 29000, quantity: "two" }] }],
  ["customer has no name", { customer: { phone: "0901000011" }, items: [{ name: "Bac xiu", price: 32000, quantity: 1 }] }],
];

console.error = () => {}; // silence the server's error line to keep the output short
for (const [name, order] of ORDERS) {
  const sent = await fetch(root, { method: "POST", body: JSON.stringify(order) });
  const list = await fetch(root);
  const tail = (await list.text()).split("\n").slice(-6).join(" ").replace(/\s+/g, " ");
  console.log(`${name}: POST returned ${sent.status}; then GET returned ${list.status}${list.ok ? ` ...${tail}` : ""}`);
}
server.close();
node naive/try.ts
valid order: POST returned 201; then GET returned 200 ... { "id": 1044, "customer": "PHẠM DUNG", "total": 58000 } ]
quantity is a word: POST returned 201; then GET returned 200 ... { "id": 1045, "customer": "PHẠM DUNG", "total": null } ]
customer has no name: POST returned 201; then GET returned 500

All three POSTs return 201: the server happily accepts everything. Look at the second line. Order 1045 has the quantity as the word "two", so the multiplication price * quantity gives NaN, and JSON.stringify turns NaN into null. The order's total shows up as null on the management page, silently, with no error anywhere. The third line is far worse: the order with a customer who has no name makes customerLabel call toUpperCase on undefined, and from then on GET /orders returns 500 to everyone, forever, until someone restarts the server and removes the broken order. One customer sending one bad order takes down the shop's whole management page.

Why doesn't as Order save us? TypeScript types only exist while you write and compile the code. When the program runs, the types are gone, and JSON.parse returns whatever is in the string that was sent. A type is a promise you make to the compiler, not a check of real data. The only place where you can check is where the data comes in.

What about writing the if statements yourself?

Many people's first reflex is to write if statements: if (typeof data.customer !== "object"), if (!Array.isArray(data.items)), if (typeof item.quantity !== "number"). I counted for this order alone: the customer has two fields, each item has three, plus rules like a whole-number quantity from 1 to 50 or a 10-digit phone number. That's about twenty conditions, each needing an error message and the location of the error in the data. Code like that is long, repetitive, and it's hard to see what the order rules actually are among a pile of ifs.

Zod lets you write those rules themselves as a declaration, like writing a description of the data, then takes care of the checking and the error reporting.

Writing a Zod schema for an order

Install Zod with npm install once you have the package.json above. The schema for an order sent by the website lives in this file. I explain each part right after the code:

src/order-schema.ts
import { z } from "zod";

import { parsePrice } from "./orders.ts";

// A price can be a number (orders from the web) or a string like "35.000" (the old machine). This reuses parsePrice from an earlier post,
// and turns its error into a Zod error so the message attaches to the right "price" field.
const priceSchema = z.union([z.number("Price must be a number or a string"), z.string("Price must be a number or a string")]).transform((raw, ctx) => {
  try {
    return parsePrice(raw);
  } catch (error) {
    ctx.issues.push({ code: "custom", message: (error as Error).message, input: raw });
    return z.NEVER;
  }
});

// Custom messages for the two errors Zod finds at object level: an unknown field and a wrong type. Other errors keep the default message.
const objectError = (issue: { code: string; keys?: string[] }) => {
  if (issue.code === "unrecognized_keys") return `Unknown field: ${issue.keys?.join(", ")}`;
  if (issue.code === "invalid_type") return "Must be a JSON object";
  return undefined;
};

const customerSchema = z.strictObject({
  name: z.string("Customer name must be text").trim().min(1, "Customer name must not be empty").max(80, "Customer name can be at most 80 characters"),
  phone: z.string("Phone number must be text").regex(/^0\d{9}$/, "Phone number must be 10 digits starting with 0"),
}, { error: objectError });

const itemSchema = z.strictObject({
  name: z.string("Item name must be text").trim().min(1, "Item name must not be empty"),
  price: priceSchema,
  quantity: z.number("Quantity must be a number").int("Quantity must be a whole number").min(1, "Quantity must be at least 1").max(50, "Quantity can be at most 50"),
}, { error: objectError });

// An order sent by the website. strictObject rejects unknown fields: the website must not set id, source or createdAt itself.
export const newOrderSchema = z.strictObject({
  customer: customerSchema,
  items: z.array(itemSchema, "Items must be an array").min(1, "An order needs at least one item").max(20, "An order can have at most 20 items"),
}, { error: objectError });

// The TypeScript type is inferred from the schema: no need to write it twice.
export type NewOrder = z.infer<typeof newOrderSchema>;

// The list of errors for the API caller: one per line, saying which field and what is wrong.
export function listIssues(error: z.ZodError): { field: string; message: string }[] {
  return error.issues.map((issue) => ({ field: issue.path.join(".") || "(whole order)", message: issue.message }));
}

(The phone rule ^0\d{9}$ is for Vietnamese mobile numbers, which have 10 digits and start with 0. Change it to whatever your market uses.) I'll explain the decisions, because each has a reason:

z.strictObject instead of z.object. By default Zod ignores unknown fields in the data, so if the website sent an extra "id": 1, Zod would quietly cut it off. I chose to reject it outright. If the website is trying to set id or source itself, that's a sign something is wrong on the sending side, and a clear error beats silently dropping the field where nobody notices. It's a choice, and you can pick z.object if you want to be lenient with a newer version of the website that adds fields.

.transform for the price. The function parsePrice from earlier posts already knows how to read "35.000" and reject "35,000", so I reuse it instead of writing a second rule. When it throws, I push that error into Zod's list of issues with ctx.issues.push, so the message "Invalid price: 35" attaches to the price field of that item. The return value is a number, so after passing the schema, the price is always a number.

.trim() on names. Zod trims extra whitespace before checking the length, so a name made only of spaces counts as empty.

z.infer. The NewOrder type is inferred from the schema itself, so you don't write a TypeScript type and then a separate set of checks that will sooner or later drift apart.

The calls z.number("...") and z.string("...") take a string, which is the message used when the data is missing or the wrong type. In Zod 4, the string passed here is used as the error message. The function objectError exists only to give a custom message to two errors that Zod words in English by default at the whole-object level, like Unrecognized key.

Connecting the schema to the server

Now replace as Order with safeParse. This function doesn't throw; it returns an object telling you whether it succeeded, along with the data or the list of errors:

src/order-server.ts
import { createServer, type IncomingMessage, type Server, type ServerResponse } from "node:http";

import { listIssues, newOrderSchema } from "./order-schema.ts";
import { listOrders, type Order } from "./orders.ts";

const JSON_HEADERS = { "content-type": "application/json; charset=utf-8" };
const TEXT_HEADERS = { "content-type": "text/plain; charset=utf-8" };
const MAX_BODY_BYTES = 100_000;

// HTTP server for orders. Takes already-normalized orders, doesn't read files, doesn't open a port itself.
export function createOrderServer(orders: Order[]): Server {
  return createServer(async (request, response) => {
    if (request.url === "/orders" && request.method === "POST") return receiveOrder(request, response, orders);
    if (request.url === "/orders") return sendOrderList(response, orders);
    response.writeHead(404).end();
  });
}

async function receiveOrder(request: IncomingMessage, response: ServerResponse, orders: Order[]): Promise<void> {
  const body = await readBody(request);
  if (body === null) return sendError(response, 413, "The request body is too large");

  let data: unknown;
  try {
    data = JSON.parse(body);
  } catch {
    return sendError(response, 400, "The request body is not valid JSON");
  }

  const result = newOrderSchema.safeParse(data);
  if (!result.success) return sendError(response, 400, "Invalid order", listIssues(result.error));

  const order: Order = {
    id: Math.max(0, ...orders.map((existing) => existing.id)) + 1,
    createdAt: new Date().toISOString(),
    source: "web",
    ...result.data,
  };
  orders.push(order);
  response.writeHead(201, JSON_HEADERS).end(JSON.stringify({ id: order.id }));
}

// Reads at most MAX_BODY_BYTES; if there is more, returns null so the caller can reject it.
async function readBody(request: IncomingMessage): Promise<string | null> {
  let body = "";
  for await (const chunk of request) {
    body += chunk;
    if (body.length > MAX_BODY_BYTES) return null;
  }
  return body;
}

function sendError(response: ServerResponse, status: number, message: string, details?: unknown): void {
  response.writeHead(status, JSON_HEADERS).end(JSON.stringify({ error: message, ...(details ? { details } : {}) }));
}

function sendOrderList(response: ServerResponse, orders: Order[]): void {
  try {
    const orderListJson = JSON.stringify(listOrders(orders), null, 2);
    response.writeHead(200, JSON_HEADERS).end(orderListJson);
  } catch (error) {
    console.error(error);
    response.writeHead(500, TEXT_HEADERS).end("Server error");
  }
}

The processing has three gates, arranged from cheap to expensive. The first is size: a body over 100,000 characters is rejected immediately with a 413 code, before any effort is spent parsing it. The second is syntax: if it isn't JSON, return 400. The third is the meaning of the data, which is the Zod schema. Only after passing all three is the order created, with the id, createdAt and source set by the server and not by the website. The variable data is declared as unknown, not any: TypeScript forces you to check it before use, and safeParse is exactly that check. After result.success, the type of result.data is NewOrder, which now matches what is really there.

Status code 400 says "you sent something wrong", while 5xx means "the server has a fault". Telling these two apart matters, because the caller needs to know whether sending again could help, and because you don't want a customer's broken order to show up in the server's error statistics.

To see the result, this script sends five orders to the new server, four broken and one valid, then reads the list back:

try-zod.ts
// Sends broken orders to the server that now has Zod and prints the errors that come back. Run: node try-zod.ts
import type { AddressInfo } from "node:net";

import { SAMPLE_ORDERS } from "./src/sample-orders.ts";
import { createOrderServer } from "./src/order-server.ts";

const server = createOrderServer([...SAMPLE_ORDERS]);
await new Promise<void>((resolve) => server.listen(0, resolve));
const root = `http://localhost:${(server.address() as AddressInfo).port}/orders`;

const customer = { name: "Phạm Dung", phone: "0901000010" };
const ORDERS: [string, string][] = [
  ["valid order", JSON.stringify({ customer, items: [{ name: "Iced milk coffee", price: "29.000", quantity: 2 }] })],
  ["quantity is a word", JSON.stringify({ customer, items: [{ name: "Iced milk coffee", price: 29000, quantity: "two" }] })],
  ["no customer name, price 35", JSON.stringify({ customer: { phone: "0901000011" }, items: [{ name: "Bac xiu", price: 35, quantity: 1 }] })],
  ["no items, extra field id", JSON.stringify({ id: 1, customer })],
  ["not JSON", "{customer:"],
];

for (const [name, body] of ORDERS) {
  const response = await fetch(root, { method: "POST", body });
  console.log(`${name}: ${response.status} ${await response.text()}`);
}
console.log(`the list afterwards: ${(await fetch(root)).status}`);
server.close();
node try-zod.ts
valid order: 201 {"id":1044}
quantity is a word: 400 {"error":"Invalid order","details":[{"field":"items.0.quantity","message":"Quantity must be a number"}]}
no customer name, price 35: 400 {"error":"Invalid order","details":[{"field":"customer.name","message":"Customer name must be text"},{"field":"items.0.price","message":"Invalid price: 35"}]}
no items, extra field id: 400 {"error":"Invalid order","details":[{"field":"items","message":"Items must be an array"},{"field":"(whole order)","message":"Unknown field: id"}]}
not JSON: 400 {"error":"The request body is not valid JSON"}
the list afterwards: 200

The valid order is accepted, and even though the website sent the price as the string "29.000", the order went in with a numeric price, thanks to transform. Each broken order is rejected with a 400 and a list of errors that points at exactly what's wrong. The third order has two errors and Zod reports both at once, so the caller fixes them in one go instead of sending again and again to discover errors one at a time. The last line shows the most important thing: the list of orders still returns 200, and no broken order got into the system.

Writing tests for the schema

A schema is code too, and it's the place that decides which data gets in. I write tests for it, with the expected error for each bad case. This works like the tests for parsePrice in earlier posts: a list of inputs with the reason for each:

src/order-schema.test.ts
import { describe, expect, test } from "vitest";

import { listIssues, newOrderSchema } from "./order-schema.ts";

const customer = { name: "Phạm Dung", phone: "0901000010" };
const validItem = { name: "Iced milk coffee", price: 29000, quantity: 2 };

// Runs the schema and returns the errors as "field: message", so the tests read like sentences.
function errorsOf(input: unknown): string[] {
  const result = newOrderSchema.safeParse(input);
  return result.success ? [] : listIssues(result.error).map((issue) => `${issue.field}: ${issue.message}`);
}

describe("newOrderSchema", () => {
  test("accepts a valid order and keeps the data as it is", () => {
    expect(newOrderSchema.parse({ customer, items: [validItem] })).toEqual({ customer, items: [validItem] });
  });

  test("a string price from the old machine is turned into a number", () => {
    const order = newOrderSchema.parse({ customer, items: [{ ...validItem, price: "35.000" }] });
    expect(order.items[0].price).toBe(35000);
  });

  test("trims extra whitespace from the customer name", () => {
    expect(newOrderSchema.parse({ customer: { ...customer, name: "  An  " }, items: [validItem] }).customer.name).toBe("An");
  });

  test.each([
    ["a quantity that is a word", { customer, items: [{ ...validItem, quantity: "two" }] }, "items.0.quantity: Quantity must be a number"],
    ["a fractional quantity", { customer, items: [{ ...validItem, quantity: 1.5 }] }, "items.0.quantity: Quantity must be a whole number"],
    ["a quantity of 0", { customer, items: [{ ...validItem, quantity: 0 }] }, "items.0.quantity: Quantity must be at least 1"],
    ["a price of 35 (lost its quotes)", { customer, items: [{ ...validItem, price: 35 }] }, "items.0.price: Invalid price: 35"],
    ["a customer with no name", { customer: { phone: "0901000010" }, items: [validItem] }, "customer.name: Customer name must be text"],
    ["a short phone number", { customer: { ...customer, phone: "090100" }, items: [validItem] }, "customer.phone: Phone number must be 10 digits starting with 0"],
    ["no items field", { customer }, "items: Items must be an array"],
    ["an empty items list", { customer, items: [] }, "items: An order needs at least one item"],
    ["an unknown field id", { id: 1, customer, items: [validItem] }, "(whole order): Unknown field: id"],
    ["a whole order that isn't an object", [], "(whole order): Must be a JSON object"],
  ])("rejects an order with %s", (_name, input, expectedError) => {
    expect(errorsOf(input)).toEqual([expectedError]);
  });

  test("reports every error at once, not just the first", () => {
    const errors = errorsOf({ customer: { phone: "1" }, items: [{ ...validItem, quantity: 0 }] });
    expect(errors).toHaveLength(3);
  });
});
npx vitest run src/order-schema.test.ts
 Test Files  1 passed (1)
      Tests  14 passed (14)

I copied only Vitest's two summary lines, leaving out the header and timing lines.

In the demo repository I also wrote four more tests for the POST /orders route itself: a valid order is added to the list with the next id, an invalid order returns 400 while the list stays unchanged, a body that isn't JSON returns 400, and a body that's too large returns 413. I don't paste them all here to keep the post from growing too long, but when you build this yourself, those are the four places to have tests. Running everything together with the older tests from earlier posts gives 40 green tests.

What about when something goes wrong and nobody is looking?

The Mộc server no longer crashes because of one broken order. A bad order is rejected immediately, with an explanation for the sender. But one thing is still true: when something happens, say the website keeps sending the same kind of wrong order at two in the morning, nobody at the shop knows. The sender gets a 400, the server stays silent, and in the morning the owner only sees that orders aren't coming in. How to make the server record what happened, enough for the next person to find the cause, is the job of later posts.

You can follow the whole story in the series Taking over a legacy project. Zod's way of declaring schemas is covered in full in the official Zod documentation. If you're preparing for interviews, questions about TypeScript and APIs are in the interview question bank.

Get the next post by email.

One email a week, only when there is something worth reading. No spam.