TypeScript gives us compile-time type safety, but that safety ends at runtime boundaries. When data comes from external sources—API requests, form inputs, third-party services—TypeScript can’t help us. This is where Zod shines.

TypeScript code editor showing type definitions with syntax highlighting

The Problem with TypeScript Alone

Consider a typical API endpoint:

interface CreateUserRequest {
  name: string;
  email: string;
  age: number;
}

app.post('/users', (req, res) => {
  const user: CreateUserRequest = req.body;
  // TypeScript trusts this completely
  // But what if req.body is actually { name: 123, email: null }?
});

TypeScript assumes req.body matches our interface. In reality, anything could come over the wire. We need runtime validation.

Enter Zod

Zod lets us define schemas that validate at runtime and infer TypeScript types:

import { z } from 'zod';

const CreateUserSchema = z.object({
  name: z.string().min(1).max(100),
  email: z.string().email(),
  age: z.number().int().positive().max(150),
});

// TypeScript type derived from the schema
type CreateUserRequest = z.infer<typeof CreateUserSchema>;

Now our type and validation live in the same place. One source of truth.

Digital shield icon representing data validation and security

Validating API Requests

Here’s how to use Zod in an Express handler:

app.post('/users', (req, res) => {
  const result = CreateUserSchema.safeParse(req.body);

  if (!result.success) {
    return res.status(400).json({
      error: 'Validation failed',
      details: result.error.flatten(),
    });
  }

  // result.data is fully typed as CreateUserRequest
  const user = result.data;
  // user.name is guaranteed to be a non-empty string
  // user.email is guaranteed to be a valid email
  // user.age is guaranteed to be a positive integer
});

The safeParse method returns a discriminated union. If validation fails, you get structured error messages. If it succeeds, you get fully-typed data.

Composing Complex Schemas

Zod schemas compose naturally:

const AddressSchema = z.object({
  street: z.string(),
  city: z.string(),
  country: z.string().length(2), // ISO country code
  postalCode: z.string().optional(),
});

const UserWithAddressSchema = CreateUserSchema.extend({
  address: AddressSchema.optional(),
  roles: z.array(z.enum(['admin', 'user', 'guest'])),
});

Error Handling Patterns

Zod errors are detailed and can be formatted for different needs:

const result = schema.safeParse(data);

if (!result.success) {
  // Flat format for simple error messages
  const flat = result.error.flatten();
  // { fieldErrors: { email: ['Invalid email'] } }

  // Full format for detailed debugging
  const issues = result.error.issues;
  // [{ path: ['email'], message: 'Invalid email', code: 'invalid_string' }]
}

Type Inference Tricks

Zod’s type inference is powerful. You can extract input vs output types:

const TransformSchema = z.object({
  dateString: z.string().transform(s => new Date(s)),
});

type Input = z.input<typeof TransformSchema>;
// { dateString: string }

type Output = z.output<typeof TransformSchema>;
// { dateString: Date }

Conclusion

Zod bridges the gap between TypeScript’s static types and runtime reality. Define your schema once, get both validation and types. Your API contracts become self-documenting and self-enforcing.

The initial setup cost is minimal, but the payoff—catching malformed data before it corrupts your system—is substantial.