TypeScript
Zod
Validation
API
Node.js
Type Safety

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

Validate untrusted API input with Zod 4: schemas and inferred types, safeParse vs parse, discriminated unions, middleware, and what changed from Zod 3.

13 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.

Everything below targets Zod 4, which is the current major and differs from most tutorials online in ways worth knowing before you copy one. The official documentation is the reference to check against when a snippet does not behave the way you expect.

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.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.

Zod 4 renamed things most tutorials still use

Zod 4 landed as the stable major in 2025, and a large share of the Zod material online predates it. The old syntax mostly still runs, which is the annoying part: you copy a snippet, it works, and you have quietly written code against an API scheduled for deletion in the next major.

The change you will hit first is string formats. They were promoted from methods to top-level functions, which reads better and tree-shakes properly.

typescript
// Zod 3, deprecated in Zod 4 but still working
const Old = z.object({
  email: z.string().email(),
  id: z.string().uuid(),
  site: z.string().url(),
});

// Zod 4
const New = z.object({
  email: z.email(),
  id: z.uuid(),
  site: z.url(),
});

Error customisation was unified next. Every z function and schema method now takes an error parameter, and message is deprecated in its favour. Two older parameters, invalid_type_error and required_error, were not deprecated but removed outright. That is the one that bites, because a Zod 3 snippet carrying required_error does not fail loudly in your editor. It just stops customising anything.

typescript
// Zod 3
z.string().min(5, { message: 'Too short' });
z.string({ required_error: 'Name is required' });

// Zod 4
z.string().min(5, { error: 'Too short' });
z.string({ error: 'Name is required' });

Error formatting moved too. error.flatten() and error.format() are both deprecated in favour of the top-level z.treeifyError(). Two smaller changes are worth knowing before they surprise you: defaults now apply to the output type rather than the input, with .prefault() added to restore the old behaviour, and .refine() no longer accepts a type predicate for narrowing.

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,
    },
  };
}

Mapping error.issues by hand like this is deliberate. It is flatter than anything z.treeifyError() produces, and the shape stays yours rather than tracking Zod's. Reach for z.treeifyError() when the consumer is a form that wants errors nested to match its own field structure.

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.

Reshaping a schema instead of writing it twice

The moment you add a second endpoint for the same resource, the copy and paste temptation arrives. A PATCH takes the same fields as the POST but all optional, and the response includes an id the request never had. Writing three near-identical schemas is how they drift.

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

// PATCH: every field optional, derived from the one above.
const UpdateUser = CreateUser.partial();

// Public response: add the server-owned fields.
const UserResponse = CreateUser.extend({
  id: z.uuid(),
  createdAt: z.iso.datetime(),
});

// Admin-only view of a subset.
const UserSummary = UserResponse.pick({ id: true, email: true });

// Anything but the role.
const SafeUser = UserResponse.omit({ role: true });

.partial() has one sharp edge worth knowing. It makes every field optional, including all of them at once, so an empty PATCH body sails through as valid and your handler writes nothing. If that matters, say so explicitly.

typescript
const UpdateUser = CreateUser.partial().refine(
  (data) => Object.keys(data).length > 0,
  { error: 'Provide at least one field to update' }
);

Discriminated unions for payloads that vary

Webhooks are the usual case: one endpoint, one shared envelope, a body whose shape depends entirely on an event type. A plain z.union handles it, badly. On failure it reports why the input failed against every branch, which is a wall of noise, and it has to try each one in turn.

typescript
const WebhookEvent = z.discriminatedUnion('type', [
  z.object({
    type: z.literal('payment.succeeded'),
    amount: z.number().int().positive(),
    currency: z.string().length(3),
  }),
  z.object({
    type: z.literal('payment.failed'),
    reason: z.enum(['card_declined', 'insufficient_funds', 'expired']),
  }),
  z.object({
    type: z.literal('subscription.cancelled'),
    cancelAt: z.iso.datetime(),
  }),
]);

const result = WebhookEvent.safeParse(req.body);
if (!result.success) return res.status(422).json(toApiError(result.error, req.id));

switch (result.data.type) {
  case 'payment.succeeded':
    // result.data.amount is a number here. No cast.
    return recordPayment(result.data.amount, result.data.currency);
  case 'payment.failed':
    return notifyFailure(result.data.reason);
  case 'subscription.cancelled':
    return scheduleCancellation(result.data.cancelAt);
}

Zod reads the discriminator first and validates against that branch only, so an unknown amount on a payment.succeeded event reports one error about one field. The inferred type is a proper tagged union, which means the switch narrows and TypeScript will tell you when the provider adds a fourth event type you have not handled.

Why does my schema reject valid query parameters?

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, {
    error: '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.email() }).refine(
  async ({ email }) => !(await db.user.findUnique({ where: { email } })),
  { error: '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.

Zod, Yup, Joi, or class-validator

Use Zod. That is the short version, and it is not close if TypeScript is already in the project. The longer version is that the alternatives each have one situation where they still make sense.

Yup predates Zod and its type inference was bolted on afterwards, which shows on anything involving conditional or recursive schemas. It is still a reasonable choice if you are inside an existing Formik codebase that already depends on it. Migrating a form library and a validation library in the same week is not a good trade.

Joi comes from the Hapi world and is genuinely good at what it does, but it was designed for JavaScript and gives you no static type from a schema. In a plain JS Node service that is fine. In a TypeScript one you end up writing the interface by hand next to the schema, which is exactly the drift Zod exists to remove.

class-validator attaches rules to a class with decorators, so validation and the model live together. It needs experimentalDecorators and reflect-metadata, and it wants your data to be a class instance rather than a plain object, which is friction at a JSON boundary. If you are in NestJS it is the path of least resistance and I would not fight it. Outside NestJS I would not introduce it.

One caveat on Zod that its fans skip: it is not small. If you are validating on the client and bundle size is a real constraint, Zod 4 ships a zod/mini build with a functional API that tree-shakes much harder. Server-side, the size does not matter and the ergonomics of the standard build win.

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.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);

Note the parse rather than safeParse there. A missing database URL is not a case to handle gracefully, and crashing before the process accepts traffic is the correct outcome. It is the one place in this post where throwing is the right call.

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 the 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, usually in a function several layers from the request that introduced it. The distinction worth holding onto is that types describe what you expect and validation establishes what arrived, and only one of those survives to runtime. This applies to every boundary rather than only request bodies: query strings, webhook payloads, third-party responses and environment variables are all data you did not produce. Zod is attractive here because one declaration gives you both the check and the TypeScript type, so the two cannot drift apart.

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 object you can turn into a 422 right where the decision belongs, instead of throwing an exception that gets handled somewhere else entirely. parse throws, which suits code deep inside the application where a failure genuinely is exceptional and crashing loudly is the correct response. Parsing environment variables at startup is the clearest case for parse: a missing database URL is not something to handle gracefully, and you want the process to die before it accepts traffic. The rough rule is that parse belongs where a failure means the program is broken, and safeParse belongs where a failure means the caller is. If you find yourself wrapping parse in a try block at a request boundary, that is safeParse with extra steps.

Why does my Zod schema reject valid query parameters?

Query strings are always strings, so a page parameter arrives as the characters rather than a number and a schema expecting a number rejects every request. Use z.coerce.number for numeric parameters. Avoid z.coerce.boolean, which is the trap inside the trap: it applies the JavaScript Boolean function, and every non-empty string is truthy, so a parameter explicitly set to false becomes true and the resulting bug is invisible in the schema. Use an enum of the two literal strings with a transform instead, which is explicit and correct. Repeated parameters are the other surprise, since a key that appears once parses as a string and twice as an array, so anything that can repeat wants a schema accepting both. Give paginated endpoints sensible defaults in the schema so a bare request still works. Logging the raw query object once, before validation, resolves most of these in seconds.

Should I validate API responses as well as requests?

Yes, for third-party APIs. When a provider silently renames or removes a field, validating the response turns a confusing error about reading a property of undefined, three layers down in your rendering code, into a clear failure naming the exact path that changed at the moment it arrived. That is the difference between a five minute fix and an afternoon of bisecting. Validating environment variables at startup is worth doing on day one for the same reason, and it is the cheapest version of this idea. For your own internal APIs the case is weaker, since you control both ends and a shared type may be enough, though it still catches a deploy skew between services. If the cost concerns you, validate responses in development and staging only. Even then, log the validation failures rather than throwing, so a provider change shows up in your dashboard rather than in a user report.

What changed between Zod 3 and Zod 4?

String formats moved from methods to top-level functions, so z.string().email() becomes z.email(), which is more concise and tree-shakes properly. Error customisation was unified under a single error parameter, deprecating message and removing invalid_type_error and required_error outright. error.flatten() and error.format() were deprecated in favour of the top-level z.treeifyError(). Defaults now apply to the output type rather than the input, with a new prefault method added to restore the old behaviour, and refine no longer accepts a type predicate for narrowing. Most of the deprecated syntax still runs, which is precisely the risk: you copy a snippet from an older tutorial, it works, and you have written code against an API scheduled for deletion. The removed parameters are worse, because a snippet carrying required_error simply stops customising anything rather than failing in your editor. Run npm ls zod before following any tutorial, since the two versions look nearly identical on the page.

Should I use Zod or Yup?

Use Zod on any TypeScript project. Yup predates it and its type inference was added afterwards, which shows immediately on conditional and recursive schemas where the inferred type stops matching what the schema actually accepts. Yup remains reasonable inside an existing Formik codebase that already depends on it, because migrating a form library and a validation library in the same week is a bad trade for no user-visible benefit. Joi is genuinely good at what it does but gives you no static type from a schema, so in TypeScript you end up hand-writing the interface beside it and maintaining both. class-validator suits NestJS, where decorators are already the idiom, and is friction anywhere else because it wants class instances rather than the plain objects a JSON boundary produces. If bundle size on the client is a real constraint, Zod 4 ships a mini build with a functional API that tree-shakes considerably harder.

Related Articles