Zod Schemas vs TypeScript Interfaces: When to Use Each

Compare Zod runtime validation schemas with TypeScript compile-time interfaces. Learn when to use each and how to combine them for maximum type safety.

Advanced

Detailed Explanation

Runtime vs Compile-Time Type Safety

TypeScript interfaces and Zod schemas serve different purposes. Understanding when to use each — and how to combine them — is key to building robust applications.

TypeScript Interface (Compile-Time Only)

interface User {
  id: number;
  name: string;
  email: string;
}

// TypeScript trusts you at compile time
const user: User = JSON.parse(apiResponse);
// No runtime validation! If the API returns bad data, TypeScript won't catch it.

Zod Schema (Runtime + Compile-Time)

import { z } from "zod";

const userSchema = z.object({
  id: z.number(),
  name: z.string(),
  email: z.string().email(),
});

type User = z.infer<typeof userSchema>;

// Runtime validation catches bad data
const user = userSchema.parse(JSON.parse(apiResponse));
// Throws ZodError if data doesn't match

Comparison

Feature TypeScript Interface Zod Schema
Compile-time types Yes Yes (via z.infer)
Runtime validation No Yes
Custom validation rules No Yes (.email(), .min(), .refine())
Default values No Yes (.default())
Data transformation No Yes (.transform())
Error messages Compile errors only Detailed runtime errors
Bundle size impact None (erased) ~13KB (minified + gzipped)
Learning curve Low Medium

When to Use TypeScript Only

  • Internal data — State that never crosses a trust boundary (component props, function returns).
  • Performance-critical hot paths — Where the ~1ms parsing overhead matters.
  • Simple applications — Where you trust all data sources completely.

When to Use Zod

  • API boundaries — Validating responses from external APIs.
  • User input — Form data, query parameters, file uploads.
  • Environment variables — Config parsing at startup.
  • Database results — Verifying ORM outputs match expectations.
  • Webhook payloads — External system callbacks.

Best Practice: Combine Both

// Define schema once, derive type from it
const userSchema = z.object({
  id: z.number(),
  name: z.string(),
  email: z.string().email(),
});

// Type is derived — no duplication
type User = z.infer<typeof userSchema>;

// Use the type for internal code
function formatUser(user: User): string {
  return \`\${user.name} <\${user.email}>\`;
}

// Use the schema at trust boundaries
async function fetchUser(id: number): Promise<User> {
  const res = await fetch(\`/api/users/\${id}\`);
  return userSchema.parse(await res.json());
}

This pattern gives you the best of both worlds: zero-overhead type checking in internal code and runtime safety at boundaries.

Use Case

You are evaluating whether to add Zod to your TypeScript project and need to understand the tradeoffs between compile-time-only types and runtime validation schemas.

Try It — JSON to Zod Schema

Open full tool