|

Building Type-Safe APIs with Zod Contract

Last time we talked about Zod basics. Now let’s build something real. I’m going to show you how I set up production APIs with Zod - the validation middleware, error handling, all the stuff that actually matters.

Project Setup

Clean slate, TypeScript project:

npm init -y
npm install express zod
npm install -D typescript @types/express @types/node tsx

TypeScript config with strict checking (because we’re not animals):

{
  "compilerOptions": {
    "target": "ES2022",
    "module": "commonjs",
    "lib": ["ES2022"],
    "outDir": "./dist",
    "rootDir": "./src",
    "strict": true,
    "esModuleInterop": true,
    "skipLibCheck": true,
    "forceConsistentCasingInFileNames": true
  },
  "include": ["src/**/*"],
  "exclude": ["node_modules"]
}

Creating Contract Schemas

Here’s how I organize my contracts - everything in one place, easy to share with frontend teams:

import { z } from 'zod'

export const CreateUserContract = {
  request: z.object({
    name: z.string().min(1).max(100),
    email: z.string().email(),
    age: z.number().int().positive().optional()
  }),
  response: z.object({
    id: z.string().uuid(),
    name: z.string(),
    email: z.string(),
    age: z.number().optional(),
    createdAt: z.string().datetime()
  })
}

export const GetUserContract = {
  params: z.object({
    id: z.string().uuid()
  }),
  response: z.object({
    id: z.string().uuid(),
    name: z.string(),
    email: z.string(),
    age: z.number().optional(),
    createdAt: z.string().datetime()
  })
}

export const UpdateUserContract = {
  params: z.object({
    id: z.string().uuid()
  }),
  request: z.object({
    name: z.string().min(1).max(100).optional(),
    email: z.string().email().optional(),
    age: z.number().int().positive().optional()
  }),
  response: z.object({
    id: z.string().uuid(),
    name: z.string(),
    email: z.string(),
    age: z.number().optional(),
    updatedAt: z.string().datetime()
  })
}

export type CreateUserRequest = z.infer<typeof CreateUserContract.request>
export type CreateUserResponse = z.infer<typeof CreateUserContract.response>
export type GetUserParams = z.infer<typeof GetUserContract.params>
export type GetUserResponse = z.infer<typeof GetUserContract.response>
export type UpdateUserParams = z.infer<typeof UpdateUserContract.params>
export type UpdateUserRequest = z.infer<typeof UpdateUserContract.request>
export type UpdateUserResponse = z.infer<typeof UpdateUserContract.response>

Request Validation Middleware

This is the good stuff - reusable middleware that validates everything:

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

export const validateRequest = (schema: z.ZodSchema) => {
  return (req: Request, res: Response, next: NextFunction) => {
    try {
      req.body = schema.parse(req.body)
      next()
    } catch (error) {
      if (error instanceof ZodError) {
        return res.status(400).json({
          error: 'Validation failed',
          details: error.errors.map(err => ({
            path: err.path.join('.'),
            message: err.message
          }))
        })
      }
      next(error)
    }
  }
}

export const validateParams = (schema: z.ZodSchema) => {
  return (req: Request, res: Response, next: NextFunction) => {
    try {
      req.params = schema.parse(req.params)
      next()
    } catch (error) {
      if (error instanceof ZodError) {
        return res.status(400).json({
          error: 'Invalid parameters',
          details: error.errors.map(err => ({
            path: err.path.join('.'),
            message: err.message
          }))
        })
      }
      next(error)
    }
  }
}

Response Validation

Yeah, validate your responses too. Saved my butt more than once when I accidentally returned the wrong shape:

import { Response } from 'express'
import { z } from 'zod'

export const sendValidated = <T>(
  res: Response,
  schema: z.ZodSchema<T>,
  data: unknown,
  statusCode: number = 200
) => {
  try {
    const validated = schema.parse(data)
    return res.status(statusCode).json(validated)
  } catch (error) {
    console.error('Response validation failed:', error)
    return res.status(500).json({
      error: 'Internal server error',
      message: 'Response validation failed'
    })
  }
}

Error Handling

Keep your error handling consistent. Here’s my setup:

import { Request, Response, NextFunction } from 'express'
import { ZodError } from 'zod'

export class AppError extends Error {
  constructor(
    public statusCode: number,
    public message: string,
    public isOperational: boolean = true
  ) {
    super(message)
    Object.setPrototypeOf(this, AppError.prototype)
  }
}

export const errorHandler = (
  err: Error,
  req: Request,
  res: Response,
  next: NextFunction
) => {
  if (err instanceof AppError) {
    return res.status(err.statusCode).json({
      error: err.message
    })
  }

  if (err instanceof ZodError) {
    return res.status(400).json({
      error: 'Validation failed',
      details: err.errors
    })
  }

  console.error('Unexpected error:', err)
  return res.status(500).json({
    error: 'Internal server error'
  })
}

Real Example: User Management API

Let’s put it all together. Here’s a complete user API with all the pieces:

import express from 'express'
import { v4 as uuidv4 } from 'uuid'
import {
  CreateUserContract,
  GetUserContract,
  UpdateUserContract,
  CreateUserRequest,
  CreateUserResponse,
  GetUserResponse,
  UpdateUserRequest,
  UpdateUserResponse
} from './contracts'
import { validateRequest, validateParams } from './middleware/validation'
import { sendValidated } from './utils/response'
import { errorHandler, AppError } from './middleware/error'

const app = express()
app.use(express.json())

interface User {
  id: string
  name: string
  email: string
  age?: number
  createdAt: string
  updatedAt?: string
}

const users: Map<string, User> = new Map()

app.post(
  '/users',
  validateRequest(CreateUserContract.request),
  (req, res, next) => {
    try {
      const input = req.body as CreateUserRequest
      
      const user: User = {
        id: uuidv4(),
        name: input.name,
        email: input.email,
        age: input.age,
        createdAt: new Date().toISOString()
      }
      
      users.set(user.id, user)
      
      const response: CreateUserResponse = {
        id: user.id,
        name: user.name,
        email: user.email,
        age: user.age,
        createdAt: user.createdAt
      }
      
      sendValidated(res, CreateUserContract.response, response, 201)
    } catch (error) {
      next(error)
    }
  }
)

app.get(
  '/users/:id',
  validateParams(GetUserContract.params),
  (req, res, next) => {
    try {
      const { id } = req.params
      const user = users.get(id)
      
      if (!user) {
        throw new AppError(404, 'User not found')
      }
      
      const response: GetUserResponse = {
        id: user.id,
        name: user.name,
        email: user.email,
        age: user.age,
        createdAt: user.createdAt
      }
      
      sendValidated(res, GetUserContract.response, response)
    } catch (error) {
      next(error)
    }
  }
)

app.patch(
  '/users/:id',
  validateParams(UpdateUserContract.params),
  validateRequest(UpdateUserContract.request),
  (req, res, next) => {
    try {
      const { id } = req.params
      const updates = req.body as UpdateUserRequest
      const user = users.get(id)
      
      if (!user) {
        throw new AppError(404, 'User not found')
      }
      
      const updatedUser: User = {
        ...user,
        ...updates,
        updatedAt: new Date().toISOString()
      }
      
      users.set(id, updatedUser)
      
      const response: UpdateUserResponse = {
        id: updatedUser.id,
        name: updatedUser.name,
        email: updatedUser.email,
        age: updatedUser.age,
        updatedAt: updatedUser.updatedAt!
      }
      
      sendValidated(res, UpdateUserContract.response, response)
    } catch (error) {
      next(error)
    }
  }
)

app.use(errorHandler)

const PORT = process.env.PORT || 3000
app.listen(PORT, () => {
  console.log(`Server running on port ${PORT}`)
})

Testing

Test your contracts. Seriously, do it:

import { describe, it, expect } from 'vitest'
import { CreateUserContract, GetUserContract } from './contracts'

describe('User Contracts', () => {
  it('validates correct create user request', () => {
    const validData = {
      name: 'John Doe',
      email: '[email protected]',
      age: 30
    }
    
    const result = CreateUserContract.request.safeParse(validData)
    expect(result.success).toBe(true)
  })
  
  it('rejects invalid email', () => {
    const invalidData = {
      name: 'John Doe',
      email: 'not-an-email',
      age: 30
    }
    
    const result = CreateUserContract.request.safeParse(invalidData)
    expect(result.success).toBe(false)
  })
  
  it('rejects negative age', () => {
    const invalidData = {
      name: 'John Doe',
      email: '[email protected]',
      age: -5
    }
    
    const result = CreateUserContract.request.safeParse(invalidData)
    expect(result.success).toBe(false)
  })
  
  it('validates UUID in params', () => {
    const validParams = {
      id: '550e8400-e29b-41d4-a716-446655440000'
    }
    
    const result = GetUserContract.params.safeParse(validParams)
    expect(result.success).toBe(true)
  })
  
  it('rejects invalid UUID', () => {
    const invalidParams = {
      id: 'not-a-uuid'
    }
    
    const result = GetUserContract.params.safeParse(invalidParams)
    expect(result.success).toBe(false)
  })
})

Works with Fastify too

Not an Express fan? No problem. Zod works with everything:

import Fastify from 'fastify'
import { CreateUserContract } from './contracts'

const fastify = Fastify()

fastify.post('/users', async (request, reply) => {
  const result = CreateUserContract.request.safeParse(request.body)
  
  if (!result.success) {
    return reply.status(400).send({
      error: 'Validation failed',
      details: result.error.errors
    })
  }
  
  const input = result.data
  // ... create user logic
  
  return reply.status(201).send({
    id: '...',
    name: input.name,
    email: input.email,
    createdAt: new Date().toISOString()
  })
})

What I learned the hard way

Keep contracts in one place: Seriously, make a contracts folder. Your future self will thank you.

Validate early: Use middleware to catch bad data before it hits your business logic.

Validate responses too: I know it feels like overkill, but it catches bugs where your code doesn’t match your contract.

Use type inference: Always use z.infer. Never manually write types that match your schemas. They’ll drift apart.

Build from small pieces: Compose complex schemas from simple ones. Way easier to maintain.

Good error messages matter: Zod gives you detailed errors - use them to help your API consumers.

Test your schemas: Write tests that verify your schemas accept good data and reject bad data.

Version your APIs: When you make breaking changes, version your endpoints. Keep old versions working if you can.

Wrapping up

Building APIs with Zod changed how I work. The combo of runtime validation and compile-time types catches so many bugs before they hit production.

The patterns here - centralized contracts, validation middleware, response validation, testing - they scale. Start small, add more as you need it.

Next up: MCP server architecture and how to build servers that can actually grow with your needs.

Keep pushing forward and savor every step of your coding journey.