Michael Ouroumis logoichael Ouroumis

Type-Safe API Calls with TypeScript and Zod: Achieving True End-to-End Type Safety

Developer workspace with multiple monitors showing TypeScript code and type definitions

TL;DR: TypeScript provides compile-time type safety, but APIs can return anything at runtime. Zod bridges this gap by validating data at runtime and inferring TypeScript types from schemas. This guide covers:

  1. Why TypeScript alone isn't enough for API safety
  2. Introduction to Zod and runtime validation
  3. Building type-safe API clients with automatic type inference
  4. Advanced patterns: unions, discriminated types, and transformations
  5. Integration with React, Next.js, and tRPC
  6. Performance optimization and error handling strategies

The Problem: TypeScript's Runtime Blind Spot

TypeScript is a powerful tool for catching bugs during development, but it has a critical limitation that catches many developers off guard.

TypeScript Lies at Runtime

Consider this common pattern:

interface User { id: number email: string isActive: boolean } async function fetchUser(id: number): Promise<User> { const response = await fetch(`/api/users/${id}`) return response.json() // TypeScript trusts this is a User } const user = await fetchUser(123) console.log(user.email.toUpperCase()) // What if email is missing or null?

The issue: TypeScript assumes response.json() returns a User, but it actually returns any. If the API changes, returns an error, or has a bug, your "type-safe" code crashes at runtime.

Real-World Scenario

// Backend changes from: { id: 1, email: "user@example.com", isActive: true } // To: { id: 1, email_address: "user@example.com", active: true }

Your TypeScript code compiles fine. Your tests (if they use mocked data) pass. But in production:

user.email.toUpperCase() // TypeError: Cannot read property 'toUpperCase' of undefined

This is the "runtime type hole" problem, and it's responsible for countless production bugs.


Enter Zod: Runtime Type Validation

Zod is a TypeScript-first schema validation library that solves this problem elegantly. It allows you to define a schema once and get both runtime validation and compile-time type inference.

Basic Zod Schema

import { z } from 'zod' const UserSchema = z.object({ id: z.number(), email: z.string().email(), isActive: z.boolean(), }) // TypeScript type inferred automatically from schema type User = z.infer<typeof UserSchema> // Equivalent to: { id: number; email: string; isActive: boolean }

Validating at Runtime

async function fetchUser(id: number): Promise<User> { const response = await fetch(`/api/users/${id}`) const data = await response.json() // Validate and parse const user = UserSchema.parse(data) // Throws ZodError if invalid return user // Now truly guaranteed to be a User }

What happens when data is invalid:

try { const user = await fetchUser(123) } catch (error) { if (error instanceof z.ZodError) { console.log(error.issues) // [ // { // code: 'invalid_type', // expected: 'string', // received: 'undefined', // path: ['email'], // message: 'Required' // } // ] } }

Zod tells you exactly what's wrong, which field failed, and why.


Building a Type-Safe API Client

Let's build a production-ready API client with full type safety.

Step 1: Define Your Schemas

import { z } from 'zod' // User schema const UserSchema = z.object({ id: z.number(), email: z.string().email(), name: z.string(), isActive: z.boolean(), createdAt: z.string().datetime(), // ISO 8601 string }) // Post schema const PostSchema = z.object({ id: z.number(), title: z.string(), content: z.string(), authorId: z.number(), publishedAt: z.string().datetime().nullable(), tags: z.array(z.string()), }) // Paginated response schema (reusable) const PaginatedSchema = <T extends z.ZodTypeAny>(itemSchema: T) => z.object({ items: z.array(itemSchema), total: z.number(), page: z.number(), pageSize: z.number(), }) // API error schema const ApiErrorSchema = z.object({ error: z.string(), message: z.string(), statusCode: z.number(), }) // Type inference type User = z.infer<typeof UserSchema> type Post = z.infer<typeof PostSchema> type Paginated<T> = { items: T[] total: number page: number pageSize: number } type ApiError = z.infer<typeof ApiErrorSchema>

Step 2: Create a Type-Safe Fetcher

class ApiClient { private baseUrl: string constructor(baseUrl: string) { this.baseUrl = baseUrl } private async request<T>( endpoint: string, schema: z.ZodSchema<T>, options?: RequestInit ): Promise<T> { try { const response = await fetch(`${this.baseUrl}${endpoint}`, { headers: { 'Content-Type': 'application/json', ...options?.headers, }, ...options, }) if (!response.ok) { // Try to parse as API error const errorData = await response.json() const apiError = ApiErrorSchema.safeParse(errorData) if (apiError.success) { throw new Error( `API Error: ${apiError.data.message} (${apiError.data.statusCode})` ) } throw new Error(`HTTP ${response.status}: ${response.statusText}`) } const data = await response.json() // Validate response against schema return schema.parse(data) } catch (error) { if (error instanceof z.ZodError) { console.error('Validation error:', error.issues) throw new Error(`Invalid API response: ${error.message}`) } throw error } } // Type-safe API methods async getUser(id: number): Promise<User> { return this.request(`/users/${id}`, UserSchema) } async listUsers(page: number = 1): Promise<Paginated<User>> { return this.request( `/users?page=${page}`, PaginatedSchema(UserSchema) ) } async getPost(id: number): Promise<Post> { return this.request(`/posts/${id}`, PostSchema) } async createPost(data: Omit<Post, 'id'>): Promise<Post> { return this.request(`/posts`, PostSchema, { method: 'POST', body: JSON.stringify(data), }) } } // Usage const api = new ApiClient('https://api.example.com') const user = await api.getUser(123) // user is guaranteed to be a valid User console.log(user.email.toUpperCase()) // Safe! const users = await api.listUsers(1) // users.items is guaranteed to be User[]

Advanced Patterns

Discriminated Unions for Polymorphic Responses

Many APIs return different shapes based on a discriminator field:

const SuccessResponseSchema = z.object({ status: z.literal('success'), data: UserSchema, }) const ErrorResponseSchema = z.object({ status: z.literal('error'), error: z.string(), code: z.number(), }) const ApiResponseSchema = z.discriminatedUnion('status', [ SuccessResponseSchema, ErrorResponseSchema, ]) type ApiResponse = z.infer<typeof ApiResponseSchema> // TypeScript now knows to check the discriminator async function fetchUserSafe(id: number): Promise<User> { const response = await fetch(`/api/users/${id}`) const data = await response.json() const result = ApiResponseSchema.parse(data) if (result.status === 'success') { return result.data // TypeScript knows this is User } else { throw new Error(`Error ${result.code}: ${result.error}`) } }

Transformations and Data Normalization

Zod can transform data during validation:

const UserSchema = z .object({ id: z.number(), email: z.string().email().toLowerCase(), // Normalize email name: z.string().trim(), // Trim whitespace created_at: z.string().datetime(), // Snake case from API is_active: z.boolean(), }) .transform((data) => ({ // Transform to camelCase for your app id: data.id, email: data.email, name: data.name, createdAt: new Date(data.created_at), // Convert to Date object isActive: data.is_active, })) type User = z.infer<typeof UserSchema> // { id: number; email: string; name: string; createdAt: Date; isActive: boolean } const user = UserSchema.parse(apiData) console.log(user.createdAt.getFullYear()) // Now it's a real Date!

Optional and Default Values

const CreateUserSchema = z.object({ email: z.string().email(), name: z.string(), role: z.enum(['user', 'admin', 'moderator']).default('user'), bio: z.string().optional(), age: z.number().int().positive().optional(), }) const userData = CreateUserSchema.parse({ email: 'user@example.com', name: 'John Doe', // role defaults to 'user' // bio and age are optional })

Partial Updates

const UpdateUserSchema = UserSchema.partial() // All fields are now optional async function updateUser( id: number, updates: z.infer<typeof UpdateUserSchema> ): Promise<User> { const validUpdates = UpdateUserSchema.parse(updates) return api.request(`/users/${id}`, UserSchema, { method: 'PATCH', body: JSON.stringify(validUpdates), }) } await updateUser(123, { name: 'New Name' }) // Valid await updateUser(123, { invalid: 'field' }) // ZodError

Integration with React and Next.js

React Hook with Zod Validation

import { useState, useEffect } from 'react' import { z } from 'zod' function useTypeSafeApi<T>( url: string, schema: z.ZodSchema<T> ): { data: T | null; loading: boolean; error: string | null } { const [data, setData] = useState<T | null>(null) const [loading, setLoading] = useState(true) const [error, setError] = useState<string | null>(null) useEffect(() => { let cancelled = false async function fetchData() { try { setLoading(true) const response = await fetch(url) if (!response.ok) { throw new Error(`HTTP ${response.status}`) } const json = await response.json() const validated = schema.parse(json) if (!cancelled) { setData(validated) setError(null) } } catch (err) { if (!cancelled) { if (err instanceof z.ZodError) { setError(`Validation error: ${err.message}`) } else if (err instanceof Error) { setError(err.message) } else { setError('Unknown error') } } } finally { if (!cancelled) { setLoading(false) } } } fetchData() return () => { cancelled = true } }, [url, schema]) return { data, loading, error } } // Usage in component function UserProfile({ userId }: { userId: number }) { const { data: user, loading, error } = useTypeSafeApi( `/api/users/${userId}`, UserSchema ) if (loading) return <div>Loading...</div> if (error) return <div>Error: {error}</div> if (!user) return null // user is guaranteed to be valid return ( <div> <h1>{user.name}</h1> <p>{user.email}</p> </div> ) }

Next.js API Route with Validation

// app/api/users/route.ts import { NextRequest, NextResponse } from 'next/server' import { z } from 'zod' const CreateUserBodySchema = z.object({ email: z.string().email(), name: z.string().min(1), age: z.number().int().positive().optional(), }) export async function POST(request: NextRequest) { try { const body = await request.json() // Validate request body const validatedData = CreateUserBodySchema.parse(body) // Your business logic here const user = await createUserInDatabase(validatedData) // Validate response const validatedUser = UserSchema.parse(user) return NextResponse.json(validatedUser) } catch (error) { if (error instanceof z.ZodError) { return NextResponse.json( { error: 'Validation failed', issues: error.issues }, { status: 400 } ) } return NextResponse.json( { error: 'Internal server error' }, { status: 500 } ) } }

Integration with tRPC

If you're building a full-stack TypeScript app, tRPC + Zod is the gold standard for end-to-end type safety:

// server/router.ts import { z } from 'zod' import { initTRPC } from '@trpc/server' const t = initTRPC.create() export const appRouter = t.router({ getUser: t.procedure .input(z.number()) .output(UserSchema) .query(async ({ input }) => { const user = await db.users.findById(input) return UserSchema.parse(user) // Validated before returning }), createPost: t.procedure .input( z.object({ title: z.string(), content: z.string(), tags: z.array(z.string()), }) ) .output(PostSchema) .mutation(async ({ input }) => { const post = await db.posts.create(input) return PostSchema.parse(post) }), }) // client/api.ts import { createTRPCClient } from '@trpc/client' import type { AppRouter } from '../server/router' const client = createTRPCClient<AppRouter>({ /* ... */ }) // Fully type-safe, no manual types needed const user = await client.getUser.query(123) // user is automatically typed as User const post = await client.createPost.mutate({ title: 'Hello', content: 'World', tags: ['typescript'], }) // post is automatically typed as Post

Performance Considerations

Validation Cost

Zod validation does add overhead. Benchmarks show parsing a complex nested object takes microseconds, but at scale, this matters.

Optimization strategies:

  1. Cache schemas: Create schemas once, reuse everywhere
  2. Validate at boundaries only: Don't re-validate internal data
  3. Use .safeParse() for non-critical paths:
const result = UserSchema.safeParse(data) if (result.success) { // Use result.data } else { // Handle result.error without throwing }
  1. Lazy validation for large arrays:
const UserArraySchema = z.array(UserSchema).max(100) // Limit size
  1. Strip unknown keys for faster parsing:
const UserSchema = z .object({ id: z.number(), email: z.string(), }) .strip() // Remove extra fields instead of validating them

Error Handling Best Practices

User-Friendly Error Messages

import { fromZodError } from 'zod-validation-error' try { UserSchema.parse(data) } catch (error) { if (error instanceof z.ZodError) { const validationError = fromZodError(error) console.log(validationError.toString()) // "Validation error: Expected string, received number at "email"" } }

Custom Error Messages

const UserSchema = z.object({ email: z .string({ required_error: 'Email is required' }) .email({ message: 'Please enter a valid email address' }), age: z .number() .int() .positive({ message: 'Age must be a positive number' }) .max(120, { message: 'Age seems unrealistic' }), })

Graceful Degradation

async function fetchUserWithFallback(id: number): Promise<User | null> { try { const response = await fetch(`/api/users/${id}`) const data = await response.json() return UserSchema.parse(data) } catch (error) { console.error('Failed to fetch user:', error) return null // Return null instead of crashing the app } }

Migration Strategy: Adding Zod to Existing Projects

You don't need to convert everything at once.

Phase 1: Add to New Features

Start using Zod for all new API integrations. Keep existing code unchanged.

Phase 2: Protect Critical Paths

Add validation to your most critical or error-prone endpoints:

  • Authentication flows
  • Payment processing
  • User data updates

Phase 3: Incremental Adoption

Gradually add schemas to existing endpoints, prioritizing by:

  1. Frequency of bugs
  2. Business impact
  3. Complexity of response shape

Phase 4: Automated Detection

Add a lint rule or pre-commit hook to ensure new API calls include validation.


Comparison with Alternatives

FeatureZodYupJoiio-ts
TypeScript-firstβœ…βŒβŒβœ…
Type inferenceβœ…PartialβŒβœ…
Bundle size~8KB~13KB~145KB~6KB
Transformsβœ…βœ…βœ…βœ…
Error messagesExcellentGoodGoodComplex
Learning curveLowLowMediumHigh
CommunityLargeLargeLargeSmall

Verdict: For TypeScript projects, Zod offers the best developer experience with excellent type inference, small bundle size, and intuitive API.


Conclusion: Type Safety You Can Trust

TypeScript's compile-time checks are powerful, but they vanish at runtime. By combining TypeScript with Zod, you create a bulletproof system where:

  1. Types are validated, not assumed: Data is verified at runtime boundaries
  2. Types and validation stay in sync: Single source of truth via schema inference
  3. Errors are caught early: Invalid data fails fast with clear messages
  4. Refactoring is safe: Change the schema, TypeScript updates everywhere

Start small by adding Zod to your most critical API calls. As you experience fewer production bugs and more confidence in your data, you'll naturally expand its use.

Next steps:

  • Explore the Zod documentation
  • Try adding Zod to one API endpoint today
  • Consider tRPC for full-stack type safety
  • Share validation schemas between frontend and backend

Type safety isn't just about preventing bugs. It's about building systems you can trust, modify confidently, and maintain with clarity. Zod makes that goal achievable.


Further Reading

Enjoyed this post? Share:

Type-Safe API Calls with TypeScript and Zod: Achieving True End-to-End Type Safety – Michael Ouroumis Blog