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.