TypeScript
Zod
Validation
API
Node.js
Type Safety

Validating API Input with Zod: Schemas, Types, and Errors

Validate untrusted API input with Zod: schema basics, inferring TypeScript types, safeParse vs parse, reusable middleware, and turning issues into 400s.

10 min read
Chamikara Nayanajith

TypeScript types vanish at compile time. That is fine inside your own code and useless at the edge, where a request body typed as CreateUser is really any wearing a costume. Nothing checked it. The annotation is a promise the compiler made about data it never saw.

Zod closes that gap by making the schema the source of truth: validate at runtime, and derive the TypeScript type from the same declaration so the two cannot drift.

The lie in a typed request handler

typescript
type CreateUser = { email: string; age: number };

app.post('/users', (req, res) => {
  // A cast, not a check. req.body could be anything at all.
  const body = req.body as CreateUser;

  // Compiles. Throws at runtime when age is the string "30",
  // or undefined, or an object.
  const nextYear = body.age + 1;
});

The cast tells the compiler to stop asking questions. It does not inspect a single byte.

Schema first, type second

bash
npm install zod
typescript
import { z } from 'zod';

const CreateUser = z.object({
  email: z.string().email(),
  age: z.number().int().min(18).max(120),
  role: z.enum(['user', 'admin']).default('user'),
  bio: z.string().max(500).optional(),
});

// The type is derived, not written twice.
type CreateUser = z.infer<typeof CreateUser>;
// { email: string; age: number; role: 'user' | 'admin'; bio?: string }

z.infer is the reason to use this rather than a hand-written validator. Add a field to the schema and the type updates. Change a field to optional and every place that assumed it was present becomes a compile error. A separate interface and validator drift apart within weeks, silently.

parse or safeParse

typescript
// Throws a ZodError on failure. Good deep inside code where a
// failure genuinely is exceptional.
const user = CreateUser.parse(input);

// Returns a result. Better at an API boundary, where invalid input
// is an expected outcome rather than an exception.
const result = CreateUser.safeParse(input);
if (!result.success) {
  return res.status(422).json(toApiError(result.error));
}
const user = result.data; // fully typed

I use safeParse at every request boundary. Invalid input from a client is a normal Tuesday, not an exceptional condition, and routing it through a thrown exception means the handling lives somewhere other than where the decision belongs.

Turning issues into a useful 422

A Zod error carries structured issues with a path per problem. Mapping them to a field-level response is what lets a client highlight the right input rather than showing a banner.

typescript
import { ZodError } from 'zod';

function toApiError(error: ZodError, requestId: string) {
  return {
    error: {
      code: 'VALIDATION_FAILED',
      message: 'The request body failed validation',
      details: error.issues.map((issue) => ({
        // path is an array: ['address', 'postcode'] or ['tags', 0]
        field: issue.path.join('.'),
        message: issue.message,
      })),
      requestId,
    },
  };
}

That shape matches the single error format argued for in REST API design, which is the point: validation failures should not be a special case the client parses differently from everything else.

Reusable middleware

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

export function validateBody<T extends ZodTypeAny>(schema: T): RequestHandler {
  return (req, res, next) => {
    const result = schema.safeParse(req.body);

    if (!result.success) {
      return res.status(422).json(toApiError(result.error, req.id));
    }

    // Replace the body with the parsed value: coercions and defaults
    // applied, unknown keys stripped.
    req.body = result.data as z.infer<T>;
    next();
  };
}

app.post('/users', validateBody(CreateUser), (req, res) => {
  // req.body is validated. No cast, no optional chaining.
});

Assigning result.data back over req.body is the detail that makes this worth doing. Zod strips unknown keys by default, so a client sending { email, age, isAdmin: true } gets isAdmin removed rather than passed to your ORM. That is mass assignment prevention for free.

Query strings are all strings

The mistake that catches everyone once: ?page=2&active=true parses to { page: '2', active: 'true' }. A schema expecting numbers rejects every request.

typescript
const ListQuery = z.object({
  page: z.coerce.number().int().positive().default(1),
  limit: z.coerce.number().int().min(1).max(100).default(20),
  // Note: Boolean('false') is true, so coerce.boolean() is wrong here.
  active: z
    .enum(['true', 'false'])
    .transform((v) => v === 'true')
    .optional(),
});

z.coerce.boolean() is the trap inside the trap. It applies JavaScript's Boolean(), and every non-empty string is truthy, so ?active=false becomes true. The enum-and-transform version above is explicit and correct.

Refinements for rules across fields

typescript
const DateRange = z
  .object({
    startDate: z.coerce.date(),
    endDate: z.coerce.date(),
  })
  .refine((data) => data.endDate > data.startDate, {
    message: 'endDate must be after startDate',
    path: ['endDate'], // attach to a field, not the whole object
  });

// Async, for rules that need a lookup.
const Signup = z.object({ email: z.string().email() }).refine(
  async ({ email }) => !(await db.user.findUnique({ where: { email } })),
  { message: 'That email is already registered', path: ['email'] }
);

// Async refinements require the async parser.
const result = await Signup.safeParseAsync(req.body);

Setting path matters. Without it the issue attaches to the object root, and a client trying to show the message next to the relevant input has nothing to key on.

On the async version: a uniqueness check here is a convenience, not a guarantee. Two simultaneous signups both pass validation and both attempt the insert. Keep the database unique constraint and handle the conflict with a 409.

Validate responses too, at least in development

Input validation is the obvious half. The one people skip is checking that a third-party API returned what its docs claim.

typescript
const WeatherResponse = z.object({
  main: z.object({ temp: z.number(), humidity: z.number() }),
  weather: z.array(z.object({ description: z.string() })).min(1),
});

const res = await fetch(url);
const parsed = WeatherResponse.safeParse(await res.json());

if (!parsed.success) {
  // The provider changed their shape. Fail here, loudly, rather than
  // three layers down with "cannot read properties of undefined".
  logger.error({ issues: parsed.error.issues }, 'Unexpected weather API shape');
  throw new UpstreamError('Weather provider returned an unexpected response');
}

When a provider silently renames a field, this turns a confusing runtime error deep in your rendering code into a clear message naming the exact path that changed.

Where this fits

Validate at the boundary and nowhere else. Once data has been parsed by a schema it is trustworthy, and re-checking it in every function is noise. The boundaries are: request bodies, query strings and route params, webhook payloads, third-party API responses, and environment variables at startup.

That last one is worth doing on day one. A schema over process.env parsed at boot turns "undefined is not a function" at 3am into a startup failure that names the missing variable. It is also where secrets like a JWT signing key should be length-checked, as argued in JWT authentication in Node.js.

typescript
const Env = z.object({
  DATABASE_URL: z.string().url(),
  JWT_ACCESS_SECRET: z.string().min(32),
  PORT: z.coerce.number().default(3000),
  NODE_ENV: z.enum(['development', 'test', 'production']),
});

// Throws at startup, before the server binds, naming what is missing.
export const env = Env.parse(process.env);

The generics used throughout this, z.infer and the <T extends ZodTypeAny> in the middleware, are ordinary TypeScript features covered in React and TypeScript. Zod is a good demonstration of why they are worth learning: the entire value is that one declaration produces both the check and the type.

Frequently asked questions

Why do I need runtime validation if I have TypeScript?

TypeScript types are erased at compile time, so a request body annotated as CreateUser is really any wearing a costume. Nothing inspected it. Casting req.body tells the compiler to stop asking questions rather than checking a single byte, which is why the error surfaces later as a runtime failure deep in your code.

Should I use parse or safeParse?

safeParse at any API boundary. Invalid input from a client is an expected outcome rather than an exceptional condition, and safeParse returns a result you can turn into a 422 right where the decision belongs. parse throws, which suits code deep inside the application where a failure genuinely is exceptional.

Why does my Zod schema reject valid query parameters?

Query strings are always strings, so ?page=2 parses to the string "2" and a schema expecting a number rejects it. Use z.coerce.number(). Avoid z.coerce.boolean() though: it applies JavaScript Boolean(), and every non-empty string is truthy, so ?active=false becomes true. Use an enum with a transform instead.

Should I validate API responses as well as requests?

Yes, for third-party APIs. When a provider silently renames a field, validating the response turns a confusing "cannot read properties of undefined" three layers down into a clear failure naming the exact path that changed. Validating environment variables at startup is worth doing on day one for the same reason.

Related Articles