|

Introduction to Zod Contract - Type Safety for API Contracts

Ever had your API blow up because someone sent a string where you expected a number? Yeah, me too. That’s why I started using Zod for API contracts, and honestly, it’s been a game changer.

What is Zod?

Zod is a TypeScript validation library that does something pretty cool - you define your data structure once, and boom, you get both runtime validation AND TypeScript types. No more keeping your types and validation in sync manually. It’s built specifically for TypeScript, which means it just… works.

import { z } from 'zod'

const UserSchema = z.object({
  id: z.string().uuid(),
  name: z.string().min(1).max(100),
  email: z.string().email(),
  age: z.number().int().positive().optional()
})

type User = z.infer<typeof UserSchema>

See that last line? That’s all you need to get a TypeScript type from your schema. No duplication, no drift. Define once, use everywhere.

Why I switched to Zod for API contracts

Type Inference is magic: Write your schema once, get TypeScript types for free. I used to maintain separate type definitions and validation logic. They’d drift apart, bugs would creep in. Not anymore.

Composability: You can build complex schemas from simple ones. Need to add an address to your user? Just extend it:

const AddressSchema = z.object({
  street: z.string(),
  city: z.string(),
  zipCode: z.string().regex(/^\d{5}$/)
})

const UserWithAddressSchema = UserSchema.extend({
  address: AddressSchema
})

Error messages that actually help: When validation fails, Zod tells you exactly what went wrong and where. No more cryptic error messages.

const result = UserSchema.safeParse({
  id: 'invalid-uuid',
  name: '',
  email: 'not-an-email'
})

if (!result.success) {
  console.log(result.error.issues)
  // You get detailed info for each validation failure
}

How does it compare to other libraries?

Joi: Been around forever, but it’s not built for TypeScript. You need extra tools to generate types, and they can get out of sync.

Yup: Better TypeScript support than Joi, but type inference isn’t as smooth. More verbose too.

io-ts: Great type safety, but the learning curve is steep. Zod gives you similar guarantees with way better ergonomics.

Class-validator: Works well with classes and decorators, but not as composable as Zod.

Zod hits the sweet spot - strong types, runtime validation, and it’s actually pleasant to use.

Contract-First vs Code-First

Here’s how I think about it:

Contract-First: Define your API contracts (schemas) first, then write the code. Both client and server agree on the data structure before anyone writes a line of code.

// Define the contract
const CreateUserRequest = z.object({
  name: z.string().min(1),
  email: z.string().email()
})

const CreateUserResponse = z.object({
  id: z.string().uuid(),
  name: z.string(),
  email: z.string(),
  createdAt: z.string().datetime()
})

// Your code follows the contract
type CreateUserInput = z.infer<typeof CreateUserRequest>
type CreateUserOutput = z.infer<typeof CreateUserResponse>

Code-First: Write your code first, add validation later. This usually ends up messy. Trust me, I’ve been there.

With Zod, contract-first just makes sense. Your schemas are your documentation AND your validation. Everything stays in sync.

When should you use contract-first?

I use it when:

  • Multiple clients consume my API
  • I need to share types between frontend and backend
  • I can’t afford bugs in production (who can, really?)
  • I’m working with a team and we need clear agreements
  • I want to catch errors before they hit production

Getting started

Super simple:

npm install zod

Start small. Pick your most critical endpoint and define schemas for it. Once you see how much cleaner things get, you’ll want to use it everywhere.

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

const app = express()

const LoginSchema = z.object({
  email: z.string().email(),
  password: z.string().min(8)
})

app.post('/login', (req, res) => {
  const result = LoginSchema.safeParse(req.body)
  
  if (!result.success) {
    return res.status(400).json({ errors: result.error.issues })
  }
  
  // result.data is now typed and validated
  const { email, password } = result.data
  // ... your auth logic here
})

Wrapping up

Zod makes type safety and runtime validation work together seamlessly. No more keeping types and validation in sync manually. No more runtime surprises.

Start with one endpoint. See how it feels. I bet you’ll end up using it everywhere like I did.

Next post, we’ll build a real API with Zod - validation middleware, error handling, the whole deal.

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