Building a Type-Safe Express 5 API with Zod

One validation middleware for params, query and body, strict schemas that reject unknown fields, FormData-friendly coercion and a typed accessor: the Zod setup I use for Express 5 APIs, and the Zod 4 default that would silently break PATCH.

Cover for the article Building a Type-Safe Express 5 API with Zod

TypeScript stops at the edge of your process. Whatever arrives in req.body is whatever the client sent, and a type annotation does not change that. On the API behind this portfolio every request goes through Zod before a controller sees it, and the controller gets back a value whose type comes from the same schema that checked it. Here is the setup and the reasons behind each choice.

One middleware for params, query and body

I want every problem in a request reported at once, not one per round trip, so a single middleware checks all three parts and collects every issue with its location (body.email, query.limit).

import type { Request, RequestHandler } from 'express';
import type { z } from 'zod';

const PARTS = ['params', 'query', 'body'] as const;
type Part = (typeof PARTS)[number];

declare global {
  namespace Express {
    interface Request {
      validated?: Partial<Record<Part, unknown>>;
    }
  }
}

export function validate(schemas: Partial<Record<Part, z.ZodType>>): RequestHandler {
  return (req, res, next) => {
    const errors: { field: string; message: string }[] = [];
    const parsed: Partial<Record<Part, unknown>> = {};

    for (const part of PARTS) {
      const schema = schemas[part];
      if (!schema) continue;
      const result = schema.safeParse(req[part] ?? {});
      if (result.success) {
        parsed[part] = result.data;
      } else {
        for (const issue of result.error.issues) {
          errors.push({ field: [part, ...issue.path.map(String)].join('.'), message: issue.message });
        }
      }
    }

    if (errors.length) {
      res.status(422).json({ success: false, error: { message: 'Validation failed', errors } });
      return;
    }

    req.validated = { ...req.validated, ...parsed };
    next();
  };
}

Notice what it does not do: it never writes req.query. In Express 5, req.query is a getter, so assigning to it fails. Parsed values go to req.validated instead. (In my real middleware the errors go to next() so the central error handler formats them; the inline response keeps this sample self-contained.)

A typed accessor instead of casts

The handler should not need to know how validation is wired, and it should not cast. A tiny helper takes the schema as an argument purely for its type:

export function validated<T extends z.ZodType>(req: Request, part: Part, _schema: T): z.output<T> {
  return req.validated?.[part] as z.output<T>;
}

router.post('/projects', requireAuth, validate({ body: createProjectBody }), async (req, res) => {
  const body = validated(req, 'body', createProjectBody); // fully typed, already sanitised
  const project = await Project.create(body);
  res.status(201).json({ success: true, data: project });
});

Express 5 also forwards a rejected promise from an async handler to the error middleware by itself, so a failed Project.create reaches the same error handler as everything else.

Strict by default

Every object schema is a z.strictObject. A typo such as { "feautred": true } then fails with "Unrecognized key" instead of being quietly dropped, and a client cannot slip in fields it should never set, such as publishedAt or readingTime, which the model computes itself.

import { z } from 'zod';

export const createPostBody = z.strictObject({
  title: z.string().trim().min(1, 'Required').max(150),
  excerpt: z.string().trim().min(1, 'Required').max(300),
  content: z.string().max(200_000).transform(sanitizeHtml),
  tags: z.array(z.string().trim().toLowerCase().max(30)).max(10).optional(),
  status: z.enum(['draft', 'published'], { error: 'Status must be draft or published' }).optional(),
});

The content field shows another habit: rich text is length-checked on the raw input and then run through an HTML allowlist inside the schema. Whatever leaves validation is already safe to store, so no controller can forget to sanitise.

Inputs that arrive as strings

An admin panel often submits multipart/form-data, where everything is a string: "true", "3", '["Angular","Node"]'. Rather than duplicate schemas for JSON and FormData, the building blocks accept both:

// true / false from JSON, "true" / "false" from FormData
export const flexBool = z.union([z.boolean(), z.stringbool()], { error: 'Must be true or false' });

// 3 or "3"; a blank string counts as "not provided"
export const flexInt = (min: number, max: number) =>
  z.preprocess(
    (value) => (typeof value === 'string' && value.trim() === '' ? undefined : value),
    z.coerce.number().int().min(min).max(max).optional(),
  );

// Objects and arrays sent as JSON strings are parsed before validation
export const jsonField = <T extends z.ZodType>(schema: T) =>
  z.preprocess((value, ctx) => {
    if (typeof value !== 'string') return value;
    const trimmed = value.trim();
    if (!trimmed.startsWith('{') && !trimmed.startsWith('[')) return value;
    try {
      return JSON.parse(trimmed) as unknown;
    } catch {
      ctx.addIssue({ code: 'custom', message: 'Invalid JSON' });
      return z.NEVER;
    }
  }, schema);

The Zod 4 default that breaks PATCH

The tempting way to write an update schema is createSchema.partial(). If any field in the create schema has a .default(), that is a trap in Zod 4, because defaults are now applied even inside optional fields:

const Post = z.object({ status: z.enum(['draft', 'published']).default('draft') });

Post.partial().parse({}); // { status: 'draft' } in Zod 4

A PATCH that only changes the title would quietly unpublish the post. My rule is simple: no .default() in request schemas at all. Defaults belong to the Mongoose model, which applies them once, on create. Request schemas only describe what the client may send.

With that rule in place, I build create and update schemas from one shape object:

  • Create: listed keys are required, every other key is optional, and a blank string counts as absent.
  • Update: every key is optional, at least one must be present, and keys that may be cleared also accept null, which the service turns into a $unset.
const postShape = { title, excerpt, content, tags, status, seo };

export const createPostBody = createSchema(postShape, { required: ['title', 'excerpt', 'content'] });
export const updatePostBody = updateSchema(postShape, {
  notNullable: ['title', 'excerpt', 'content', 'tags', 'status', 'seo'],
});

Validation is not the last line of defence

Zod checks the shape of the input. The Mongoose schema still declares lengths, enums and patterns, and updates load the document, apply the change and call save(), so model validators and hooks (slugs, publish dates, reading time) always run. On top of that, Mongoose's sanitizeFilter is switched on, so a { "$ne": null } that somehow reached a query filter would be treated as a value, not an operator.

What this buys you

  • One place to read what an endpoint accepts.
  • Controllers with real types and no casts.
  • Every validation error in one response, each with a precise field path.
  • Unknown fields rejected instead of silently ignored.
  • PATCH requests that change only what the client sent.

None of this needs a framework on top of Express. It is a few dozen lines of building blocks, and every new endpoint gets the same guarantees for free.