Type-Safe APIs With TypeScript and Zod: End-to-End Validation

By Eoin Villarin, 2026-01-20, updated 2026-10-28

TypeScript types vanish at runtime. Learn how Zod 4 validates API requests, query strings, env vars and third-party data, and how to share one schema end to end.

How do I use Zod validation to build type-safe APIs with TypeScript?

Zod validation makes TypeScript APIs type-safe at runtime by checking real data against a schema, while z.infer derives the static type from that same schema. Parse request bodies, query strings, environment variables and third-party responses at every boundary, return a 400 for invalid requests, and share schemas across the server, tRPC, Next.js Server Actions and forms.

Key takeaways

  • Zod 4 parses strings about 14.7 times, arrays 7.4 times and objects 6.5 times faster than Zod 3.
  • Zod 4's core bundle for a simple script is 5.36 KB gzipped, down from 12.47 KB in Zod 3.
  • Use Zod's safeParse in API handlers so validation failures become 400 responses instead of thrown exceptions.
  • Use z.stringbool() for boolean query flags, because z.coerce.boolean() turns the string false into true.
  • Parse process.env with a Zod schema at startup so a missing secret fails the deploy, not a customer request.

TypeScript catches a huge class of bugs at compile time, but it cannot see the JSON that arrives at your API at runtime. Zod validation closes that gap: you describe the shape of your data once, Zod checks real values at runtime, and TypeScript infers the static type from the very same schema.

That combination is why Zod has become the default validation library across much of the TypeScript ecosystem, from tRPC to Next.js forms. Zod 4, now the stable major version, made it significantly faster and smaller while cleaning up several awkward APIs.

In this guide you will learn how schemas and inference work, what changed in Zod 4, and how to validate request bodies, query strings, environment variables and third-party responses, then share one schema across your server, client and forms.

Why TypeScript types alone do not protect your API

TypeScript types are erased when your code compiles to JavaScript. When a request body, webhook payload or third-party response arrives, nothing checks that it matches the interface you declared. A cast such as req.body as Order simply tells the compiler to trust you.

That is how apps written entirely in TypeScript still crash with "Cannot read properties of undefined" in production. A missing field, a number sent as a string or an unexpected null flows straight into business logic. The fix is to validate at every boundary where untrusted data enters your system:

  • HTTP request bodies, route params and query strings.
  • Responses from third-party APIs and incoming webhooks.
  • Environment variables and configuration files.
  • Form submissions, local storage and URL state on the client.

Inside those boundaries, TypeScript strict mode can do its job properly, because every value really has the type it claims to have.

How Zod validation works: schemas, parsing and inference

A Zod schema describes data. You compose primitives such as strings, numbers and booleans into objects and arrays, and you can reuse schemas inside other schemas, just like types.

Pile of green toy building bricks, small pieces that compose into larger schemas
Schemas compose, and types are inferred from them. Photo: ChrisEdwardsCE, CC0
import { z } from 'zod';

export const AddressSchema = z.object({
  line1: z.string().trim().min(1),
  city: z.string().trim().min(1),
  postcode: z.string().regex(/^\d{4}$/, { error: 'Use a 4-digit postcode' }),
});

export const CustomerSchema = z.object({
  id: z.uuid(),
  email: z.email(),
  name: z.string().trim().min(2),
  address: AddressSchema,
  createdAt: z.iso.datetime(),
});

export type Customer = z.infer<typeof CustomerSchema>;

z.infer extracts the TypeScript type, so you never write a separate interface that can drift away from the validation rules. Note the Zod 4 top-level formats such as z.email(), z.uuid() and z.iso.datetime(), which replace the older z.string().email() style.

parse versus safeParse

.parse() returns typed data or throws a ZodError. .safeParse() never throws and returns a discriminated union: { success: true, data } or { success: false, error }. In API handlers I almost always use safeParse, because a validation failure is an expected outcome that should become a 400 response, not an exception.

Transforming data while validating

Schemas can also clean data as they check it. .trim() and .toLowerCase() normalise strings, .transform() converts values into new shapes, and z.coerce converts inputs such as query strings into numbers or dates. The inferred output type reflects every transform automatically.

What changed in Zod 4

Zod 4 was a ground-up rewrite focused on speed, type-checking performance and bundle size. The Zod 4 release notes report these improvements over Zod 3:

  • String parsing about 14.7 times faster, array parsing 7.4 times faster and object parsing 6.5 times faster.
  • Far fewer TypeScript type instantiations, which speeds up editors and tsc on large schema files.
  • A core bundle of 5.36 KB gzipped for a simple script, down from 12.47 KB, plus zod/mini, a tree-shakable functional API at 1.88 KB.
  • Built-in JSON Schema output with z.toJSONSchema(), typed metadata with .meta() and readable errors with z.prettifyError().

If you are upgrading an existing codebase, these are the API changes you will hit most often. The full list is in the Zod 4 migration guide.

Zod 3Zod 4Notes
z.string().email()z.email()The method form still works but is deprecated
message, errorMaperrorOne parameter for all error customisation
error.flatten(), error.format()z.flattenError(), z.treeifyError()Error formatting moved to top-level functions
.strict(), .passthrough()z.strictObject(), z.looseObject()Explicit object constructors
.merge().extend().merge() is deprecated
z.nativeEnum()z.enum()z.enum() now accepts TypeScript enums
z.record(valueSchema)z.record(keySchema, valueSchema)The single-argument form was removed

How to validate API request bodies and query params

The pattern is the same in Express, Hono, Fastify or a plain Node.js handler: parse at the edge of the route and only pass validated data inward.

Key turning in a lock on a wooden door, checking requests before they get inside
Validate at the edge, then trust the data inside. Photo: rawpixel, CC0
  1. Parse the input with safeParse() as the first thing the route does.
  2. Return a 400 response with formatted, field-level errors when parsing fails.
  3. Pass only result.data to your business logic, never the raw request.
  4. Validate the output too when it includes data from untrusted or external sources.

Here is a reusable Express middleware that handles the first three steps:

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

export function validateBody<T extends z.ZodType>(schema: T) {
  return (req: Request, res: Response, next: NextFunction) => {
    const result = schema.safeParse(req.body);
    if (!result.success) {
      return res.status(400).json(z.flattenError(result.error));
    }
    req.body = result.data;
    next();
  };
}

// usage
app.post('/api/customers', validateBody(CustomerSchema), createCustomer);

z.flattenError() returns an object with formErrors for top-level issues and fieldErrors keyed by field name, which maps neatly onto form error messages in the client. If you run APIs on Cloudflare Workers with Hono, its Zod validator middleware follows the same idea.

Coercing query strings

Query parameters always arrive as strings. Coercion converts them before the validation rules run:

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

const query = ListOrdersQuery.parse(req.query);
// query is { page: number; limit: number; archived: boolean }

Be careful with z.coerce.boolean(): it uses JavaScript truthiness, so the string "false" becomes true. z.stringbool() is the correct choice for flags, because it understands values such as "true", "1", "yes", "false", "0" and "no".

Validating environment variables and third-party responses

Two boundaries are often forgotten. The first is configuration. Parse process.env once at startup so a missing secret fails the deploy instead of the first customer request:

import { z } from 'zod';

const EnvSchema = z.object({
  NODE_ENV: z.enum(['development', 'test', 'production']),
  DATABASE_URL: z.url(),
  CMS_URL: z.url(),
  PAYMENT_API_KEY: z.string().min(32),
  ENABLE_CACHE: z.stringbool().default(false),
});

export const env = EnvSchema.parse(process.env);

The second is external APIs. A payment provider, CMS or shipping API can change its response without warning. Parse the response before using it, and log the formatted error when it fails, so you learn about the change from your logs rather than from customers:

const res = await fetch(`${env.CMS_URL}/products/${id}`);
const parsed = ProductSchema.safeParse(await res.json());

if (!parsed.success) {
  console.error(z.prettifyError(parsed.error));
  throw new Error('CMS returned an unexpected product shape');
}

return parsed.data;

For APIs you call constantly, consider whether a typed contract such as GraphQL codegen or tRPC is a better fit than hand-written schemas. My comparison of GraphQL, REST and tRPC walks through the trade-offs.

How to share one schema across tRPC, Next.js and forms

The biggest payoff from Zod validation is reuse. Define a schema in a shared module and use it on the server, in the client and in your forms, so the rules can never drift apart.

Worker holding rolled up building plans, one blueprint shared by every team
One schema shared by server, client and forms. Photo: rawpixel, CC0

tRPC procedures

tRPC calls Zod its default recommendation. Pass a schema to .input() and the procedure validates every call, infers the input type and returns typed errors to the client. The tRPC validator documentation also supports any library that implements Standard Schema.

export const orderRouter = router({
  byId: publicProcedure
    .input(z.object({ id: z.uuid() }))
    .query(async ({ input }) => {
      // input.id is a validated string here
      return getOrder(input.id);
    }),
});

Next.js Server Actions

Server Actions receive FormData, and the Next.js forms guide recommends a schema library such as Zod for server-side validation. Convert the form data with Object.fromEntries() and parse it. Zod's default object behaviour strips unknown keys, including the internal $ACTION_ fields Next.js adds:

'use server';

import { z } from 'zod';
import { ContactSchema } from '@/lib/schemas';

export async function submitContact(prevState: unknown, formData: FormData) {
  const result = ContactSchema.safeParse(Object.fromEntries(formData));
  if (!result.success) {
    return { ok: false, errors: z.flattenError(result.error).fieldErrors };
  }
  await saveEnquiry(result.data);
  return { ok: true, errors: {} };
}

React Hook Form

On the client, zodResolver from @hookform/resolvers runs the same schema for instant feedback: useForm({ resolver: zodResolver(ContactSchema) }). The server still validates, because client-side checks are a convenience, not a security control. For a complete project using this pattern, see my guide to building a full-stack app with Next.js and Supabase.

Common Zod mistakes and how to avoid them

Validate at the boundary, trust inside it. Once data has passed a schema, the rest of your code should never need to check it again.

  • Validating only on the client. Anyone can bypass your form and call the API directly.
  • Creating schemas inside hot functions. Define schemas at module level so they are built once, not on every request.
  • Using .default() the Zod 3 way. In Zod 4 the default must match the output type. Use .prefault() if you need the old behaviour.
  • Leaking internal fields. Use output schemas, or .pick() and .omit(), to control exactly what an API returns.
  • Returning raw error objects. Format them with z.flattenError() or z.treeifyError() and avoid exposing internal details to the client.

Frequently asked questions

Is Zod still the best validation library for TypeScript?

For most projects, yes. Zod has the largest ecosystem, first-class support in tRPC, React Hook Form and many frameworks, and Zod 4 closed much of the performance gap with alternatives. Valibot and ArkType are strong options when bundle size or raw speed is critical, and Standard Schema lets many tools accept any of them.

Should I upgrade from Zod 3 to Zod 4?

Yes, for actively maintained projects. Zod 4 is faster, smaller and easier to type-check, and most schemas work unchanged. The main work is replacing deprecated APIs such as message parameters, error.flatten() and z.string().email(). Upgrade with npm install zod@^4, run your type checker and tests, then clean up deprecation warnings gradually.

Does Zod validation slow down my API?

Rarely in a way you will notice. Zod 4 parses objects several times faster than Zod 3, and validation is usually tiny compared with database queries or network calls. Define schemas once at module level, avoid unnecessary transforms on hot paths, and measure with real payloads if you validate very large arrays.

Can Zod generate JSON Schema or OpenAPI documents?

Yes. Zod 4 includes z.toJSONSchema(), which converts schemas into JSON Schema and includes metadata added with .meta(). You can feed that output into OpenAPI tooling or AI tool definitions, so your documentation, runtime validation and TypeScript types all come from one single source.

Next steps: add Zod validation at every boundary

Start with the riskiest entry point in your app, usually a public API route or webhook, and add a schema there today. Then validate environment variables at startup and move shared schemas into a module that both your client and server import.

If you need help hardening an existing API or building a type-safe app from scratch, reach out through my contact form or look through my case studies to see the projects I have delivered.

Related reading