Validation with Zod

Never Trust Input — Validate at the Boundary

Validation with Zod

Zod validates user input AND infers the TS type. The standard for Node APIs.

4 min read Level 2/5 #nodejs#validation#zod
What you'll learn
  • Define a Zod schema
  • Validate bodies and query params
  • Return clean error responses

User input is untrusted — never assume it’s the right shape. Validate at the boundary. Zod is the standard tool in Node.

Install

npm install zod

A Schema

import { z } from "zod";

const CreateUserSchema = z.object({
  name:  z.string().min(1).max(60),
  email: z.string().email(),
  age:   z.number().int().min(0).max(120).optional(),
});

Validating a Body

import express from "express";
import { z } from "zod";

const app = express();
app.use(express.json());

app.post("/api/users", (req, res) => {
  const parsed = CreateUserSchema.safeParse(req.body);
  if (!parsed.success) {
    return res.status(400).json({
      error: { code: "bad_request", issues: parsed.error.issues },
    });
  }
  const user = parsed.data;   // typed AND validated
  // save user...
  res.status(201).json(user);
});

safeParse returns { success: true, data } or { success: false, error }. No throws.

A Validate Middleware

Reuse across many routes:

function validate(schema) {
  return (req, res, next) => {
    const parsed = schema.safeParse(req.body);
    if (!parsed.success) {
      return res.status(400).json({ error: { issues: parsed.error.issues } });
    }
    req.validBody = parsed.data;
    next();
  };
}

app.post("/api/users", validate(CreateUserSchema), (req, res) => {
  // req.validBody is typed
});

Coercing Query Strings

Query values are strings. Zod can coerce:

const QuerySchema = z.object({
  page:  z.coerce.number().int().min(1).default(1),
  limit: z.coerce.number().int().min(1).max(100).default(20),
});

app.get("/api/users", (req, res) => {
  const { page, limit } = QuerySchema.parse(req.query);
  // page, limit are numbers
});

Deriving the Type (for TS projects)

import { z } from "zod";

const CreateUserSchema = z.object({ /* ... */ });

type CreateUser = z.infer<typeof CreateUserSchema>;
// { name: string; email: string; age?: number }

One source of truth — runtime AND type-time.

Why Not Just TypeScript?

TS types are erased at runtime. An attacker sending { "age": "drop tables" } would slip past TS but fail Zod. Always validate at the boundary.

Authentication →