← Back to list

Stop Trusting Your API Responses — Use Zod + TypeScript to Validate Everything at Runtime

Your TypeScript types disappear at runtime. Here’s how to make your API layer bulletproof without writing a single extra type by hand.

Kevin - MERN Stack Developer · 2026-03-26 13:36 · 51 claps · 4.0 min read paywalled
#typescript #zod #api #web-development #javascript
Open on Medium ↗
Wiki topics: 🌐 · Web Development 📐 · Mathematics

Stop Trusting Your API Responses — Use Zod + TypeScript to Validate Everything at Runtime

Your TypeScript types disappear at runtime. Here’s how to make your API layer bulletproof without writing a single extra type by hand.

You’ve seen it before. When the TypeScript compiler provides you a green build with no errors. You ship. Suddenly production is on fire because an API returned null instead of a string. TypeScript checked your code — but it never checked your data. That gap is where bugs live.

This is the gap that Zod was designed to fill.

The Fundamental Problem with Just TypeScript

TypeScript is a compile-time tool. When your app runs and calls out a fetch(), TypeScript has no control over what gets returned. You just cast it as User, assuming the shape, and carry on. Until you shouldn’t have.

The dangerous pattern looks innocent:

// This looks fine. It's not.
const res = await fetch('/api/user/42')
const user = await res.json() as User

// TypeScript is happy. Runtime? Good luck.
console.log(user.email.toLowerCase())

It was — If the API ever leaves { email: null } — backend bug, schema migration, or a third-party API bill gating on a bad day — that line throws runtime error which TypeScript never warned you about.

Introducing Zod : Brought to you by types-after-runtime-validation

Zod, allows you to specify a schema once, runtime type validation and return a TypeScript type from that same schema. No duplication. No lying to the compiler.

import { z } from 'zod'

// One schema. One source of truth.
const UserSchema = z.object({
  id: z.string().uuid(),
  name: z.string().min(2).max(100),
  email: z.string().email(),
  role: z.enum(['admin', 'user', 'viewer']),
  createdAt: z.string().datetime(),
})

// Type inferred automatically — no hand-written interface needed
type User = z.infer<typeof UserSchema>

That User type is exactly as you’d hand-write it but instead it is creating it off the schema that is validating your data. They can never drift apart.

How NOT to Validate API Response

So here’s what that looks like in a live Next. API route in Next.js 14 with the App Router:

// app/api/users/[id]/route.ts
import { z } from 'zod'
import { NextResponse } from 'next/server'

const UserSchema = z.object({
  id: z.string().uuid(),
  name: z.string().min(2),
  email: z.string().email(),
  role: z.enum(['admin', 'user', 'viewer']),
})

export async function GET(
  req: Request,
  { params }: { params: { id: string } }
) {
  const raw = await fetchUserFromDB(params.id) // returns unknown

  // safeParse never throws — it returns a result object
  const parsed = UserSchema.safeParse(raw)

  if (!parsed.success) {
    // parsed.error.issues gives you granular field-level errors
    console.error('Invalid user shape:', parsed.error.issues)
    return NextResponse.json({ error: 'Invalid data shape' }, { status: 500 })
  }

  // parsed.data is fully typed as User — no cast needed
  return NextResponse.json(parsed.data)
}

See what changed: raw is seen as an unknown, and you only get a typed User once the validation is passed. The compiler and runtime will be happy. That’s the goal.

Validating External APIs (Third-Party Data)

With third-party APIs you don’t control this pattern is even more important.

// services/github.ts
const GithubUserSchema = z.object({
  login: z.string(),
  id: z.number(),
  avatar_url: z.string().url(),
  public_repos: z.number().nonnegative(),
  // only pick what you actually need
})

type GithubUser = z.infer<typeof GithubUserSchema>

export async function getGithubUser(username: string): Promise<GithubUser> {
  const res = await fetch(`https://api.github.com/users/${username}`)
  const data = await res.json()

  // If GitHub ever changes their API, you'll know immediately
  return GithubUserSchema.parse(data)
}

Zod doesn’t leave you hanging — when the parse fails — and it will eventually fail — Zod throws a ZodError with a full representation of every field that didn’t match A useful error message instead of a Cannot read property 'x' of undefined stack trace at 3am.

Composing Schemas for Real Complexity

Real APIs aren’t flat objects. Zod knows how to take care of nested shapes, optional fields and arrays and unions in a clean way:

const AddressSchema = z.object({
  street: z.string(),
  city: z.string(),
  country: z.string().length(2), // ISO country code
})

const OrderSchema = z.object({
  orderId: z.string().uuid(),
  status: z.enum(['pending', 'processing', 'shipped', 'delivered']),
  items: z.array(
    z.object({
      productId: z.string(),
      quantity: z.number().int().positive(),
      price: z.number().positive(),
    })
  ).min(1),
  shippingAddress: AddressSchema,
  discount: z.number().min(0).max(100).optional(),
})

type Order = z.infer<typeof OrderSchema>

Each constraint has a readable constraint, each type is inferred, and each failed validation error indicates exactly which field broke.

What This Opens Up for Your Stack

Zod has combined with TypeScript in the context of a site built using React + Next. When working on a js project, a couple of things take place that adjusts your way of building:

  • Form validation — share the same schemas with react-hook-form via the @hookform/resolvers/zod adapter; One source of truth for your form validation and API validation
  • tRPC support — tRPC uses Zod by default for its input/output validation, giving you full type safety all the way from your database to your React component and absolutely no extra typing required
  • Make error messages user-friendly — Zod’s. The customization of .message() allows you to write validation copy once at the schema level.
  • Mocking becomes super easy — your Zod schema is your contract; generate test data based on your contract with @anatine/zod-mock

Key Takeaways

  • Runtime erasure of TypeScript types — without a layer to validate your APIs these offer zero protection against bad data
  • With Zod schemas you have a single source of truth — define it once, validate it at runtime, generate your TypeScript type automatically
  • Opt for safeParse at your API boundaries — it returns a result object instead of throwing, force you to decide what to do on error.
  • Knowledge that every external data is a unknown — third party APIs change, your schema will catch it before your users
  • Zod build — nest schemas, combine with unions, share pieces across your frontend and backend

You’ll wonder how you shipped anything without it by the first time Zod catches a shape mismatch in production before it leads to a crash that a user ever sees. Start with one API route. The pattern spreads itself.


메타데이터
post_id
fd4ef1ac3897
slug
stop-trusting-your-api-responses-use-zod-typescript-to-validate-everything-at-runtime-fd4ef1ac3897
url
https://medium.com/@mernstackdevbykevin/stop-trusting-your-api-responses-use-zod-typescript-to-validate-everything-at-runtime-fd4ef1ac3897
canonical_url
https://medium.com/@mernstackdevbykevin/stop-trusting-your-api-responses-use-zod-typescript-to-validate-everything-at-runtime-fd4ef1ac3897
author_url
https://medium.com/@mernstackdevbykevin
status
ok
fetched_at
2026-06-14 11:28:49