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.
