March 26, 20266 min read

Zod — Runtime Type Validation for TypeScript

How to use Zod for schema validation in TypeScript — API inputs, form data, environment variables, and config files with complete type safety.

zod validation typescript schema types
Ad 336x280

TypeScript checks types at compile time. That's useful until your app receives data from the outside world — API requests, form submissions, environment variables, JSON files, third-party APIs. None of that data has been type-checked. Your User type says email is a string, but the actual request body might have email: 42 or no email at all.

Zod bridges this gap. You define a schema, Zod validates the data at runtime, and then TypeScript infers the type from the schema. One schema, used for both validation and typing. No duplication. No drift.

The Basics

bun add zod
import { z } from "zod";

// Define a schema
const userSchema = z.object({
name: z.string().min(1).max(100),
email: z.string().email(),
age: z.number().int().positive().max(150),
role: z.enum(["admin", "user", "moderator"]),
});

// Infer the TypeScript type from the schema
type User = z.infer<typeof userSchema>;
// type User = {
// name: string;
// email: string;
// age: number;
// role: "admin" | "user" | "moderator";
// }

// Validate data
const result = userSchema.safeParse({
name: "Alice",
email: "alice@example.com",
age: 30,
role: "admin",
});

if (result.success) {
console.log(result.data); // Typed as User
} else {
console.log(result.error.issues);
// [{ code: "...", message: "...", path: ["fieldName"] }]
}

The safeParse method never throws — it returns a discriminated union. Check result.success and TypeScript narrows the type. If you prefer exceptions, use parse:

try {
  const user = userSchema.parse(untrustedData); // Throws ZodError on failure
  // user is typed as User
} catch (e) {
  if (e instanceof z.ZodError) {
    console.log(e.issues);
  }
}

Schema Types

Zod covers every data type you'll encounter:

// Primitives
z.string()
z.number()
z.boolean()
z.bigint()
z.date()
z.undefined()
z.null()
z.void()

// String validations
z.string().email()
z.string().url()
z.string().uuid()
z.string().regex(/^[a-z]+$/)
z.string().min(5)
z.string().max(100)
z.string().startsWith("https://")
z.string().includes("@")
z.string().trim()
z.string().toLowerCase()

// Number validations
z.number().int()
z.number().positive()
z.number().nonnegative()
z.number().min(0).max(100)
z.number().multipleOf(5)
z.number().finite()

// Arrays
z.array(z.string()) // string[]
z.array(z.number()).min(1) // At least one element
z.array(z.string()).max(10) // At most 10 elements
z.string().array() // Same as z.array(z.string())

// Tuples
z.tuple([z.string(), z.number()]) // [string, number]

// Objects
z.object({
name: z.string(),
nested: z.object({
value: z.number(),
}),
})

// Records (dictionaries)
z.record(z.string(), z.number()) // Record<string, number>

// Unions
z.union([z.string(), z.number()]) // string | number
z.string().or(z.number()) // Same thing

// Discriminated unions
z.discriminatedUnion("type", [
z.object({ type: z.literal("text"), content: z.string() }),
z.object({ type: z.literal("image"), url: z.string().url() }),
z.object({ type: z.literal("video"), url: z.string().url(), duration: z.number() }),
])

// Enums
z.enum(["draft", "published", "archived"])
z.nativeEnum(MyTypescriptEnum)

// Literals
z.literal("active")
z.literal(42)
z.literal(true)

Object Manipulation

const userSchema = z.object({
  id: z.number(),
  name: z.string(),
  email: z.string().email(),
  password: z.string(),
  role: z.enum(["admin", "user"]),
  createdAt: z.date(),
});

// Pick specific fields
const loginSchema = userSchema.pick({ email: true, password: true });
// { email: string; password: string }

// Omit fields
const publicUserSchema = userSchema.omit({ password: true });
// { id: number; name: string; email: string; role: ...; createdAt: Date }

// Make all fields optional
const updateUserSchema = userSchema.partial();
// All fields are optional

// Make specific fields optional
const createUserSchema = userSchema.partial({ id: true, createdAt: true });
// id and createdAt are optional, rest required

// Extend with additional fields
const adminSchema = userSchema.extend({
permissions: z.array(z.string()),
lastLogin: z.date().optional(),
});

// Merge two schemas
const merged = schemaA.merge(schemaB);

Transforms and Coercion

Zod can transform data as it validates — parsing strings into numbers, trimming whitespace, normalizing formats.

// Coercion — parse strings into proper types (great for query params)
const querySchema = z.object({
  page: z.coerce.number().int().positive().default(1),
  limit: z.coerce.number().int().min(1).max(100).default(20),
  active: z.coerce.boolean().default(true),
});

// "?page=3&limit=50&active=false" → { page: 3, limit: 50, active: false }
querySchema.parse({ page: "3", limit: "50", active: "false" });

// Custom transforms
const slugSchema = z.string().transform((val) =>
val.toLowerCase().replace(/\s+/g, "-").replace(/[^a-z0-9-]/g, "")
);

slugSchema.parse("Hello World!"); // "hello-world"

// Transform with validation
const monetarySchema = z.string()
.regex(/^\d+\.\d{2}$/, "Must be in format '10.99'")
.transform((val) => parseFloat(val));

// Preprocess — run a function BEFORE validation
const numberFromString = z.preprocess(
(val) => (typeof val === "string" ? parseInt(val, 10) : val),
z.number().int().positive()
);

Practical Patterns

API Request Validation

import { Hono } from "hono";
import { zValidator } from "@hono/zod-validator";
import { z } from "zod";

const createPostSchema = z.object({
title: z.string().min(1).max(200),
content: z.string().min(10),
tags: z.array(z.string()).max(5).default([]),
publishAt: z.string().datetime().optional(),
});

type CreatePostInput = z.infer<typeof createPostSchema>;

app.post(
"/api/posts",
zValidator("json", createPostSchema),
async (c) => {
const data = c.req.valid("json"); // Typed as CreatePostInput
// data is guaranteed to be valid here
}
);

Environment Variables

// env.ts — validate your env vars at startup, crash early if misconfigured
const envSchema = z.object({
  DATABASE_URL: z.string().url(),
  API_KEY: z.string().min(20),
  PORT: z.coerce.number().default(3000),
  NODE_ENV: z.enum(["development", "production", "test"]).default("development"),
  REDIS_URL: z.string().url().optional(),
  LOG_LEVEL: z.enum(["debug", "info", "warn", "error"]).default("info"),
});

export const env = envSchema.parse(process.env);

// Now env.DATABASE_URL is typed as string (not string | undefined)
// If DATABASE_URL is missing, the app crashes at startup with a clear error

Form Validation (React)

import { z } from "zod";

const signupSchema = z.object({
username: z.string()
.min(3, "Username must be at least 3 characters")
.max(20, "Username must be under 20 characters")
.regex(/^[a-zA-Z0-9_]+$/, "Only letters, numbers, and underscores"),
email: z.string().email("Invalid email address"),
password: z.string()
.min(8, "Password must be at least 8 characters")
.regex(/[A-Z]/, "Must contain an uppercase letter")
.regex(/[0-9]/, "Must contain a number"),
confirmPassword: z.string(),
}).refine((data) => data.password === data.confirmPassword, {
message: "Passwords don't match",
path: ["confirmPassword"],
});

// Use with react-hook-form
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";

type SignupForm = z.infer<typeof signupSchema>;

function SignupPage() {
const { register, handleSubmit, formState: { errors } } = useForm<SignupForm>({
resolver: zodResolver(signupSchema),
});

// errors.username?.message, errors.email?.message, etc.
}

API Response Validation

Don't just trust API responses. Validate them.

const githubUserSchema = z.object({
  login: z.string(),
  id: z.number(),
  avatar_url: z.string().url(),
  name: z.string().nullable(),
  public_repos: z.number(),
});

type GitHubUser = z.infer<typeof githubUserSchema>;

async function getGitHubUser(username: string): Promise<GitHubUser> {
const response = await fetch(https://api.github.com/users/${username});
const data = await response.json();
return githubUserSchema.parse(data); // Validates and types the response
}

Zod vs Alternatives

FeatureZodYupJoiio-tsValibot
TypeScript-firstYesPartialNoYesYes
Type inferenceExcellentLimitedManualExcellentExcellent
Bundle size~13KB~15KB~30KB~8KB~1KB per validator
RuntimeBrowser + NodeBrowser + NodeNode focusedBrowser + NodeBrowser + Node
EcosystemGrowing fastMatureMatureNicheGrowing
Learning curveLowLowMediumHighLow
Choose Zod when you want the best balance of type inference, ecosystem support, and developer experience. It's the default choice for most TypeScript projects now. Consider Valibot if bundle size is critical — it's tree-shakeable at the individual validator level. Consider io-ts if you're deep into functional programming patterns.

Zod has become infrastructure. It's in tRPC, Hono, React Hook Form, Next.js server actions, Astro content schemas, and dozens of other tools. Learning it well pays dividends across your entire TypeScript stack. For more TypeScript tooling guides, check out CodeUp.

Ad 728x90