In the previous tutorial, we learned about testing TypeScript with Vitest. Now let’s learn about Zod — a library that validates data at runtime and generates TypeScript types from schemas.
By the end of this tutorial, you will know how to create Zod schemas, validate data, infer types, use transforms, and validate API requests and environment variables.
Why Do You Need Zod?
TypeScript types only exist at compile time. They disappear when your code runs. This means:
interface User {
name: string;
email: string;
age: number;
}
// This compiles fine, but at runtime the data could be anything
const user: User = await response.json();
// What if the API returns { name: 123 }? No error at runtime!
Zod validates data at runtime and gives you TypeScript types automatically:
import { z } from "zod";
const UserSchema = z.object({
name: z.string(),
email: z.string().email(),
age: z.number().min(0).max(150),
});
// Infer the TypeScript type from the schema
type User = z.infer<typeof UserSchema>;
// Result: { name: string; email: string; age: number }
// Validate at runtime — throws if invalid
const user = UserSchema.parse(await response.json());
Installation
npm install zod
Zod is tested against TypeScript 5.5+ and requires strict mode in your tsconfig.json. Older TypeScript versions may work but are not officially supported.
Basic Schemas
Primitive Types
import { z } from "zod";
const stringSchema = z.string();
const numberSchema = z.number();
const booleanSchema = z.boolean();
const dateSchema = z.date();
const bigintSchema = z.bigint();
Validation
const nameSchema = z.string();
nameSchema.parse("Alex"); // Returns "Alex"
nameSchema.parse(123); // Throws ZodError
Safe Parsing
safeParse returns a result object instead of throwing:
const result = nameSchema.safeParse("Alex");
if (result.success) {
console.log(result.data); // "Alex" — typed as string
} else {
console.log(result.error.issues); // Array of error details
}
This is similar to the Result type pattern from the error handling tutorial.
String Validations
const emailSchema = z.string().email();
const urlSchema = z.string().url();
const uuidSchema = z.string().uuid();
const passwordSchema = z.string()
.min(8, "Password must be at least 8 characters")
.max(100, "Password must be at most 100 characters")
.regex(/[A-Z]/, "Password must contain an uppercase letter")
.regex(/[0-9]/, "Password must contain a number");
const trimmedSchema = z.string().trim(); // Trims whitespace before validation
Number Validations
const ageSchema = z.number()
.int("Age must be a whole number")
.min(0, "Age cannot be negative")
.max(150, "Age cannot exceed 150");
const priceSchema = z.number()
.positive("Price must be positive")
.multipleOf(0.01); // Two decimal places
Object Schemas
const UserSchema = z.object({
id: z.number(),
name: z.string().min(1),
email: z.string().email(),
age: z.number().int().min(0).optional(), // optional field
role: z.enum(["admin", "editor", "viewer"]),
});
type User = z.infer<typeof UserSchema>;
// Result:
// {
// id: number;
// name: string;
// email: string;
// age?: number | undefined;
// role: "admin" | "editor" | "viewer";
// }
Nested Objects
const AddressSchema = z.object({
street: z.string(),
city: z.string(),
country: z.string(),
zip: z.string(),
});
const UserWithAddressSchema = z.object({
name: z.string(),
email: z.string().email(),
address: AddressSchema,
});
type UserWithAddress = z.infer<typeof UserWithAddressSchema>;
Partial and Required
Zod has its own partial() and required() methods:
const PartialUser = UserSchema.partial();
// All fields become optional
const RequiredUser = UserSchema.required();
// All fields become required (removes optional)
// Make specific fields optional
const UpdateUser = UserSchema.partial().required({ id: true });
// id is required, everything else is optional
Pick and Omit
const UserPreview = UserSchema.pick({ id: true, name: true });
// Only id and name
const UserWithoutId = UserSchema.omit({ id: true });
// Everything except id
Array Schemas
const tagsSchema = z.array(z.string());
// string[]
const numbersSchema = z.array(z.number()).min(1).max(10);
// number[] with 1-10 items
const usersSchema = z.array(UserSchema);
// User[]
Union and Enum Schemas
Unions
const resultSchema = z.union([z.string(), z.number()]);
// string | number
// Shorthand
const idSchema = z.string().or(z.number());
// string | number
Discriminated Unions
const EventSchema = z.discriminatedUnion("type", [
z.object({ type: z.literal("click"), x: z.number(), y: z.number() }),
z.object({ type: z.literal("keypress"), key: z.string() }),
z.object({ type: z.literal("scroll"), offset: z.number() }),
]);
type Event = z.infer<typeof EventSchema>;
Enums
const RoleSchema = z.enum(["admin", "editor", "viewer"]);
type Role = z.infer<typeof RoleSchema>; // "admin" | "editor" | "viewer"
// Access enum values
RoleSchema.enum.admin; // "admin"
RoleSchema.options; // ["admin", "editor", "viewer"]
Transform and Refine
Transform — Change the Value
const numberFromString = z.string().transform(val => parseInt(val, 10));
numberFromString.parse("42"); // Returns 42 (number, not string)
type Result = z.infer<typeof numberFromString>; // number
Chaining Transforms
const slugSchema = z.string()
.trim()
.toLowerCase()
.transform(val => val.replace(/\s+/g, "-"))
.transform(val => val.replace(/[^a-z0-9-]/g, ""));
slugSchema.parse(" Hello World! "); // "hello-world"
Refine — Custom Validation
const passwordSchema = z.string()
.min(8)
.refine(val => /[A-Z]/.test(val), {
message: "Must contain an uppercase letter",
})
.refine(val => /[0-9]/.test(val), {
message: "Must contain a number",
});
Superrefine — Multiple Errors
const formSchema = z.object({
password: z.string().min(8),
confirmPassword: z.string(),
}).superRefine((data, ctx) => {
if (data.password !== data.confirmPassword) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "Passwords do not match",
path: ["confirmPassword"],
});
}
});
Default Values
const ConfigSchema = z.object({
host: z.string().default("localhost"),
port: z.number().default(3000),
debug: z.boolean().default(false),
});
ConfigSchema.parse({}); // { host: "localhost", port: 3000, debug: false }
ConfigSchema.parse({ port: 8080 }); // { host: "localhost", port: 8080, debug: false }
Validating API Requests
Express Middleware
import { z } from "zod";
import { Request, Response, NextFunction } from "express";
function validate<T extends z.ZodType>(schema: T) {
return (req: Request, res: Response, next: NextFunction) => {
const result = schema.safeParse(req.body);
if (!result.success) {
res.status(400).json({
errors: result.error.issues.map(i => ({
path: i.path.join("."),
message: i.message,
})),
});
return;
}
req.body = result.data;
next();
};
}
const CreateNoteSchema = z.object({
title: z.string().min(1).max(200),
content: z.string().min(1),
tags: z.array(z.string()).optional(),
});
app.post("/notes", validate(CreateNoteSchema), (req, res) => {
// req.body is validated and typed
const { title, content, tags } = req.body;
res.status(201).json({ title, content, tags });
});
Validating Environment Variables
const EnvSchema = z.object({
PORT: z.string().transform(Number).pipe(z.number().int().positive()),
DATABASE_URL: z.string().url(),
JWT_SECRET: z.string().min(32),
NODE_ENV: z.enum(["development", "production", "test"]).default("development"),
});
export const env = EnvSchema.parse(process.env);
// env.PORT is number (transformed from string)
// env.DATABASE_URL is string (validated as URL)
// env.NODE_ENV is "development" | "production" | "test"
If any variable is missing or invalid, Zod throws with a clear error message at startup.
Zod vs Other Libraries
| Feature | Zod | Yup | Joi |
|---|---|---|---|
| TypeScript first | Yes | Partial | No |
| Type inference | z.infer<> | Limited | No |
| Zero dependencies | Yes | No | No |
| Bundle size | ~13KB | ~25KB | ~150KB |
| Ecosystem | tRPC, React Hook Form | Formik | Hapi |
Zod is the best choice for TypeScript projects because of its first-class type inference.
What’s Next?
In the next tutorial, we will learn about tRPC — how to build end-to-end type-safe APIs using Zod for input validation.