# Type-Safe APIs with Zod + Express: No More `as any`
By Govind Bajaj · 2025

Tags: typescript, zod, express, validation, api-design
I replaced Joi with Zod in three Express projects. I deleted 200 lines of type declarations and caught 14 type mismatches at build time. Here's the setup.
I replaced Joi with Zod in three Express projects over the past year.

In each project, I deleted roughly 200 lines of manual TypeScript type declarations. I caught fourteen type mismatches between the API and the frontend at build time — not at runtime, not in production, not in a bug report from a user. At build time.

Zod is a schema validation library that infers TypeScript types from your validation schemas. One source of truth. Runtime validation and compile-time types, guaranteed to match.

## The Problem with Separate Validation and Types

Before Zod, my Express code looked like this:

```ts
// types/product.ts — manual type declaration
interface CreateProductBody {
  name: string
  price: number
  category: string
  inventory?: number
}

// validation/product.ts — Joi validation (separate from types)
const createProductSchema = Joi.object({
  name: Joi.string().required(),
  price: Joi.number().positive().required(),
  category: Joi.string().required(),
  inventory: Joi.number().integer().min(0)
})

// routes/product.ts — using both
router.post('/', (req, res) => {
  const { error } = createProductSchema.validate(req.body)
  if (error) return res.status(400).json({ error: error.message })
  
  const body = req.body as CreateProductBody // trust me, it's fine
  // ...
})
```

Two problems: the type and the schema can drift out of sync, and the `as` cast bypasses TypeScript's checking. I had a bug where `inventory` was made required in the schema but remained optional in the type. The validation passed, the type system was happy, and the database constraint failed at runtime.

## The Zod Approach: Schema Is the Type

```ts
// schemas/product.ts — one source of truth
import { z } from 'zod'

export const createProductSchema = z.object({
  name: z.string().min(1),
  price: z.number().positive(),
  category: z.string(),
  inventory: z.number().int().min(0).optional()
})

// Infer the TypeScript type from the schema
export type CreateProductBody = z.infer<typeof createProductSchema>
// Equivalent to: { name: string, price: number, category: string, inventory?: number }
```

The `z.infer` utility extracts the TypeScript type from the Zod schema. If you change the schema, the type changes automatically. They cannot drift apart because they're the same thing.

## The Express Middleware Pattern

I create a validate middleware factory that integrates Zod with Express request handling:

```ts
// middleware/validate.ts
import { Request, Response, NextFunction } from 'express'
import { z, ZodError } from 'zod'

export function validate(schema: {
  body?: z.ZodTypeAny
  query?: z.ZodTypeAny
  params?: z.ZodTypeAny
}) {
  return (req: Request, res: Response, next: NextFunction) => {
    try {
      if (schema.body) {
        req.body = schema.body.parse(req.body)
      }
      if (schema.query) {
        req.query = schema.query.parse(req.query)
      }
      if (schema.params) {
        req.params = schema.params.parse(req.params)
      }
      next()
    } catch (error) {
      if (error instanceof ZodError) {
        return res.status(400).json({
          success: false,
          error: {
            code: 'VALIDATION_ERROR',
            message: 'Invalid request data',
            details: error.errors.map(e => ({
              path: e.path.join('.'),
              message: e.message
            }))
          }
        })
      }
      next(error)
    }
  }
}
```

Usage in routes:

```ts
// routes/product.ts
import { Router } from 'express'
import { validate } from '../middleware/validate'
import { createProductSchema } from '../schemas/product'
import { asyncHandler } from '../middleware/asyncHandler'

const router = Router()

router.post(
  '/',
  validate({ body: createProductSchema }),
  asyncHandler(async (req, res) => {
    // req.body is fully typed as CreateProductBody
    // TypeScript knows: req.body.name is string, req.body.inventory is number | undefined
    const product = await Product.create(req.body)
    res.status(201).json(product)
  })
)
```

The `validate` middleware parses and type-checks the request body before it reaches the route handler. If validation fails, the route handler never runs. If it passes, `req.body` is typed correctly.

## Handling Coercion

HTTP request bodies are strings. Zod's default behavior is strict — a string that looks like a number fails `z.number()` validation. Use `z.coerce` for automatic type conversion:

```ts
const querySchema = z.object({
  page: z.coerce.number().int().min(1).default(1),
  limit: z.coerce.number().int().min(1).max(100).default(20),
  search: z.string().optional()
})

// GET /products?page=2&limit=50
// req.query.page is number 2, not string "2"
```

## Reusing Schemas for Frontend Forms

Because Zod schemas are plain JavaScript objects, you can share them between backend and frontend. The same validation that protects your API also validates your forms:

```tsx
// Shared schema (in a shared package or monorepo)
import { createProductSchema } from '@sansherbals/schemas'
import { zodResolver } from '@hookform/resolvers/zod'
import { useForm } from 'react-hook-form'

function ProductForm() {
  const form = useForm({
    resolver: zodResolver(createProductSchema),
    // Form validation uses the exact same rules as the API
  })
  
  async function onSubmit(data) {
    await fetch('/api/products', {
      method: 'POST',
      body: JSON.stringify(data)
    })
  }
  
  return <form onSubmit={form.handleSubmit(onSubmit)}>{/* ... */}</form>
}
```

One schema. Frontend validation. Backend validation. One source of truth.

## Takeaways

- Zod infers TypeScript types from validation schemas — one source of truth, never out of sync
- The `validate` middleware pattern integrates Zod cleanly with Express routes
- Use `z.coerce` to handle string-to-number conversion from HTTP query parameters
- Zod schemas can be shared between frontend and backend for consistent validation
- `z.infer` replaces manual TypeScript interfaces — delete them and let Zod generate types
- Every `as any` in your codebase is a bug waiting to happen; Zod eliminates the need for them