Chapter 18

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:


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.

Rate this chapter
4.7  / 5  (12 ratings)

๐Ÿ’ฌ Comments