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.
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-formvia the@hookform/resolvers/zodadapter; 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