Runtime Validation: zod with Automatic Type Inference
The Fundamental Gap
TypeScript's type system only exists at compile time. When your program runs and receives API responses, reads user input, or parses environment variables, TypeScript is gone. That means:
// You expect the API to return this type
interface User {
id: number;
name: string;
email: string;
}
// At runtime, fetch().json() returns any
const response = await fetch("/api/user/1");
const user = await response.json() as User; // as just tells the compiler "trust me"
// The API backend returns { id: "abc", name: null, email: "..." }
// TypeScript compiles fine, runtime crash
console.log(user.id.toFixed(2)); // TypeError: user.id.toFixed is not a function
as User is a lie. You're forcing a type assertion into the shape you expect, but the actual data can be completely different.
zod solves this by letting you define a schema that genuinely validates data at runtime while automatically inferring the TypeScript type.
zod Basics: A Schema That Is Both Validator and Type
npm install zod
import { z } from "zod";
// Define a schema
const UserSchema = z.object({
id: z.number(),
name: z.string(),
email: z.string().email(),
});
// TypeScript type inferred automatically from the schema
type User = z.infer<typeof UserSchema>;
// Equivalent to:
// type User = {
// id: number;
// name: string;
// email: string;
// }
// Runtime validation
const data = JSON.parse(responseText);
const user = UserSchema.parse(data); // returns User on success, throws on failure
// user is now a genuine User type, not an assertion
Write the schema once; the type is derived, not manually synced. This eliminates an entire class of bugs where the type definition and runtime check drift out of sync.
Common zod Types
Primitive types
import { z } from "zod";
z.string()
z.number()
z.boolean()
z.date()
z.bigint()
z.symbol()
z.undefined()
z.null()
z.any()
z.unknown()
z.never()
z.void()
// Literals
z.literal("admin") // only accepts the string "admin"
z.literal(42)
z.literal(true)
String refinements
const EmailSchema = z.string()
.email("Invalid email address")
.toLowerCase() // transform: convert to lowercase
.trim(); // transform: strip whitespace
const PasswordSchema = z.string()
.min(8, "Password must be at least 8 characters")
.max(100, "Password cannot exceed 100 characters")
.regex(/[A-Z]/, "Must contain at least one uppercase letter")
.regex(/[0-9]/, "Must contain at least one number");
const SlugSchema = z.string()
.regex(/^[a-z0-9-]+$/, "Only lowercase letters, numbers, and hyphens");
const UrlSchema = z.string().url("Invalid URL");
// UUID
const IdSchema = z.string().uuid("Invalid ID format");
Number refinements
const AgeSchema = z.number()
.int("Age must be an integer")
.min(0, "Age cannot be negative")
.max(150, "Age out of range");
const PriceSchema = z.number()
.positive("Price must be positive")
.finite("Price cannot be infinite");
const PercentSchema = z.number().min(0).max(100);
Enums
// zod enum (recommended for fixed string sets)
const StatusSchema = z.enum(["pending", "active", "inactive", "deleted"]);
type Status = z.infer<typeof StatusSchema>; // "pending" | "active" | "inactive" | "deleted"
// Works with TypeScript native enums too
enum Role {
Admin = "admin",
User = "user",
Guest = "guest",
}
const RoleSchema = z.nativeEnum(Role);
type RoleType = z.infer<typeof RoleSchema>; // Role
Arrays and objects
// Arrays
const TagsSchema = z.array(z.string()).min(1, "At least one tag required").max(10, "Maximum 10 tags");
// Tuples (fixed length and types)
const CoordinateSchema = z.tuple([z.number(), z.number()]); // [lat, lng]
// Objects
const AddressSchema = z.object({
street: z.string(),
city: z.string(),
country: z.string().length(2, "Country code must be 2 characters"),
zipCode: z.string().optional(),
});
// Nested objects
const UserSchema = z.object({
id: z.string().uuid(),
name: z.string().min(1),
email: z.string().email(),
address: AddressSchema.optional(),
tags: z.array(z.string()).default([]),
createdAt: z.date(),
});
Optional, nullable, default values
z.string().optional() // string | undefined
z.string().nullable() // string | null
z.string().nullish() // string | null | undefined
z.string().default("anonymous") // uses default value when input is undefined
z.number().default(() => Date.now()) // dynamic default value
Unions and intersections
// Union types
const StringOrNumber = z.union([z.string(), z.number()]);
// Shorthand:
const StringOrNumber2 = z.string().or(z.number());
// Discriminated union (more efficient parsing)
const ShapeSchema = z.discriminatedUnion("type", [
z.object({ type: z.literal("circle"), radius: z.number() }),
z.object({ type: z.literal("rectangle"), width: z.number(), height: z.number() }),
]);
type Shape = z.infer<typeof ShapeSchema>;
// { type: "circle"; radius: number } | { type: "rectangle"; width: number; height: number }
// Intersection types
const WithTimestampsSchema = z.object({
createdAt: z.date(),
updatedAt: z.date(),
});
const ArticleSchema = z.object({
title: z.string(),
content: z.string(),
}).merge(WithTimestampsSchema); // same effect as z.intersection(...)
parse() vs safeParse(): Throw vs Return Result
const UserSchema = z.object({
name: z.string(),
age: z.number().int().positive(),
});
// parse(): throws ZodError on validation failure
try {
const user = UserSchema.parse({ name: "Alice", age: -5 });
} catch (err) {
if (err instanceof z.ZodError) {
console.log(err.issues);
// [{ code: 'too_small', minimum: 0, type: 'number', message: 'Number must be greater than 0', path: ['age'] }]
}
}
// safeParse(): returns { success: boolean, data?: T, error?: ZodError }
const result = UserSchema.safeParse({ name: "Alice", age: -5 });
if (result.success) {
console.log(result.data); // User type
} else {
console.log(result.error.issues); // ZodIssue[]
}
// TypeScript narrows correctly based on result.success:
// When true: result.data exists
// When false: result.error exists
When to use which:
parse(): when you expect data to always be valid (internal data) — failure means a bug, throwing is appropriatesafeParse(): when handling external input (API request bodies, form data) — need user-friendly error messages
Real Use Case 1: Validating API Responses
Replace as ApiResponse with real runtime validation.
import { z } from "zod";
const ProductSchema = z.object({
id: z.number(),
name: z.string(),
price: z.number().positive(),
category: z.enum(["electronics", "clothing", "food"]),
inStock: z.boolean(),
images: z.array(z.string().url()),
metadata: z.record(z.string(), z.unknown()).optional(),
});
type Product = z.infer<typeof ProductSchema>;
const ProductListSchema = z.object({
items: z.array(ProductSchema),
total: z.number().int().nonnegative(),
page: z.number().int().positive(),
pageSize: z.number().int().positive(),
});
// A typed fetch wrapper with validation
async function fetchProducts(page: number): Promise<z.infer<typeof ProductListSchema>> {
const response = await fetch(`/api/products?page=${page}`);
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
const data = await response.json();
// Actually validates the data — not pretending
const result = ProductListSchema.safeParse(data);
if (!result.success) {
console.error("API response validation failed:", result.error.format());
throw new Error("Invalid API response shape");
}
return result.data; // genuine ProductList type, not an assertion
}
Real Use Case 2: Environment Variables Validation at Startup
Validate all required environment variables on the first line of startup, rather than crashing randomly throughout runtime.
import { z } from "zod";
const EnvSchema = z.object({
// Database
DATABASE_URL: z.string().url("DATABASE_URL must be a valid URL"),
DATABASE_POOL_SIZE: z.coerce.number().int().min(1).max(100).default(10),
// Auth
JWT_SECRET: z.string().min(32, "JWT_SECRET must be at least 32 characters"),
JWT_EXPIRES_IN: z.string().default("7d"),
// App config
PORT: z.coerce.number().int().min(1).max(65535).default(3000),
NODE_ENV: z.enum(["development", "production", "test"]).default("development"),
LOG_LEVEL: z.enum(["debug", "info", "warn", "error"]).default("info"),
// External services (optional)
REDIS_URL: z.string().url().optional(),
SMTP_HOST: z.string().optional(),
SMTP_PORT: z.coerce.number().int().optional(),
});
type Env = z.infer<typeof EnvSchema>;
// Execute at module top level — validates immediately at startup
const parseResult = EnvSchema.safeParse(process.env);
if (!parseResult.success) {
console.error("Environment variable configuration errors:");
for (const issue of parseResult.error.issues) {
const path = issue.path.join(".");
console.error(` ${path}: ${issue.message}`);
}
process.exit(1); // fail fast — don't run with a broken config
}
// Export validated env — the entire app uses this
export const env: Env = parseResult.data;
// Usage:
// import { env } from "./env";
// const db = createConnection(env.DATABASE_URL);
z.coerce.number() is the key detail — environment variables are always strings, and coerce attempts a type conversion before validating, turning "3000" into 3000.
Real Use Case 3: Form Validation with Error Messages
import { z } from "zod";
const RegisterSchema = z.object({
username: z.string()
.min(3, "Username must be at least 3 characters")
.max(20, "Username cannot exceed 20 characters")
.regex(/^[a-zA-Z0-9_]+$/, "Username can only contain letters, numbers, and underscores"),
email: z.string()
.email("Please enter a valid email address"),
password: z.string()
.min(8, "Password must be at least 8 characters")
.regex(/[A-Z]/, "Password must contain at least one uppercase letter")
.regex(/[0-9]/, "Password must contain at least one number"),
confirmPassword: z.string(),
agreeToTerms: z.boolean().refine(val => val === true, {
message: "You must agree to the terms of service",
}),
}).refine(data => data.password === data.confirmPassword, {
message: "Passwords do not match",
path: ["confirmPassword"], // error attached to the confirmPassword field
});
type RegisterForm = z.infer<typeof RegisterSchema>;
function handleRegister(formData: unknown) {
const result = RegisterSchema.safeParse(formData);
if (!result.success) {
// Convert errors to a field map (suitable for displaying in form UI)
const fieldErrors = result.error.flatten().fieldErrors;
// {
// username: ["Username must be at least 3 characters"],
// email: ["Please enter a valid email address"],
// confirmPassword: ["Passwords do not match"]
// }
return { success: false, errors: fieldErrors };
}
const { username, email, password } = result.data;
return { success: true, data: { username, email, password } };
}
Transforming Data: .transform() and .preprocess()
zod schemas do more than validate — they can transform data during validation.
// .transform(): transform after validation passes
const DateFromStringSchema = z.string()
.datetime()
.transform(str => new Date(str));
type DateFromString = z.infer<typeof DateFromStringSchema>; // Date (not string!)
const date = DateFromStringSchema.parse("2024-01-15T10:30:00Z"); // Date object
// More complex transforms
const UserInputSchema = z.object({
name: z.string().trim(),
email: z.string().email().toLowerCase(),
birthYear: z.number().int(),
}).transform(data => ({
...data,
age: new Date().getFullYear() - data.birthYear, // derived field
displayName: data.name.split(" ")[0], // derived field
}));
type UserDisplay = z.infer<typeof UserInputSchema>;
// { name: string; email: string; birthYear: number; age: number; displayName: string }
// .preprocess(): pre-process before validation (type coercion)
const NumberFromAnythingSchema = z.preprocess(
(val) => {
if (typeof val === "string") return parseFloat(val);
if (typeof val === "boolean") return val ? 1 : 0;
return val;
},
z.number()
);
NumberFromAnythingSchema.parse("3.14"); // 3.14
NumberFromAnythingSchema.parse(true); // 1
NumberFromAnythingSchema.parse(42); // 42
The difference between .preprocess() and z.coerce: coerce uses JavaScript's built-in type coercion rules; preprocess gives you full control over the conversion logic.
Comparison Table: zod vs io-ts vs valibot vs arktype
| Feature | zod | io-ts | valibot | arktype |
|---|---|---|---|---|
| Type inference | z.infer<typeof S> |
t.TypeOf<typeof S> |
InferOutput<typeof S> |
typeof S.infer |
| Bundle size | ~14KB gzip | ~6KB | ~10KB (modular) | ~12KB |
| Error messages | Built-in, customizable | Needs fp-ts | Built-in | Built-in |
| Learning curve | Low | High (fp-ts dependency) | Low | Medium |
| Performance | Moderate | Moderate | Faster | Fastest |
| Ecosystem maturity | Most mature | Mature | Growing | Emerging |
| Async validation | Supported | Limited | Supported | Supported |
| Best for | General use | Functional programming | Bundle-size-sensitive | Maximum performance |
In 2024–2025, zod is the default choice for most projects primarily because its ecosystem is the most mature: tRPC, React Hook Form, Prisma, and others have first-class zod integrations.
Anti-Patterns
Anti-pattern 1: Using zod inside internal function calls everywhere
// Wrong: zod validation has overhead — don't use it in hot paths
function calculateTotal(items: CartItem[]): number {
// Wrong: items is already type-safe — no need to re-validate
const validated = z.array(CartItemSchema).parse(items);
return validated.reduce((sum, item) => sum + item.price * item.quantity, 0);
}
// Correct: validate only at boundaries (API input, env vars, etc.)
// Internal functions trust TypeScript types
function calculateTotal(items: CartItem[]): number {
return items.reduce((sum, item) => sum + item.price * item.quantity, 0);
}
// In the API handler, validate once on the way in
app.post("/cart/checkout", async (req, res) => {
const result = CheckoutRequestSchema.safeParse(req.body);
if (!result.success) return res.status(400).json({ errors: result.error.format() });
// Everything passed to internal functions is already validated
const total = calculateTotal(result.data.items);
});
Anti-pattern 2: Calling safeParse but forgetting to check success
// Wrong: called safeParse but didn't check success
const result = UserSchema.safeParse(data);
console.log(result.data.name); // result.data might be undefined
// Correct
const result = UserSchema.safeParse(data);
if (result.success) {
console.log(result.data.name); // safe
}
Anti-pattern 3: Using z.any() to dodge complexity
// Wrong: defeats the entire purpose of using zod
const ApiResponseSchema = z.object({
status: z.string(),
data: z.any(), // effectively no validation
});
// Correct: use z.unknown() and refine at usage sites
const ApiResponseSchema = z.object({
status: z.string(),
data: z.unknown(),
});
function processApiData(schema: z.ZodType, rawData: unknown) {
return schema.parse(rawData);
}
Summary Table
| Scenario | Recommended approach | Reason |
|---|---|---|
| API response validation | safeParse() + error handling |
External data is untrusted |
| Startup env var validation | parse() + process.exit(1) |
Failure means don't start |
| Form validation | safeParse() + flatten() |
Need field-level error messages |
| Internal function arguments | No zod | TypeScript types are sufficient |
| Data transformation | .transform() |
Combines validation and conversion |
| String-to-number coercion | z.coerce.number() |
Common with env vars and query params |
| Cross-field validation | .refine() |
Password confirm, date ranges, etc. |
Next Chapter
Chapter 19 covers how to incrementally migrate a 100,000-line JavaScript codebase to TypeScript: why big-bang rewrites inevitably fail, and how to migrate smoothly across 4 phases — each with concrete file-by-file strategies and measurable milestones.