TypeScript & Zod
I. TypeScript
- TypeScript is JavaScript with type annotations added on top
- It compiles down to plain JavaScript — the browser/Node only ever runs JS
- Types are a developer tool only — they disappear completely at runtime
- Catches bugs while you're writing code, not when users are using your app
Your .ts file → TypeScript compiler (tsc) → .js file → runs in Node/browser
↑ ↑
types live here types are gone here
1. Compile Time vs Runtime
Compile time feels like spell-check — flags problems before anything runs. Runtime is live — real data is flowing, real users are clicking.
- Compile time = when you're writing code and your editor underlines things in red, or when your build fails
- Runtime = when the actual program is running — a user clicks a button, a server handles a request, data comes back from an API
TypeScript only exists at compile time. When your code compiles to JavaScript, all the types are completely erased:
// TypeScript type
interface User {
id: number;
name: string;
}
// At runtime (in JavaScript), this becomes... nothing.
// The interface is completely gone.
2. Basic Type Annotations
// Primitives
let name: string = "Cam";
let age: number = 25;
let isActive: boolean = true;
// Arrays
let scores: number[] = [1, 2, 3];
let names: string[] = ["Cam", "Mai"];
// Union types — can be one OR the other
let id: string | number = "abc123";
id = 42; // also fine
// Optional — value or undefined
let nickname: string | undefined;
// Literal types — exact value only
let direction: "left" | "right" | "up" | "down" = "left";
3. Interfaces and Types
Both describe the shape of an object. Use either — they're mostly interchangeable.
// Interface
interface User {
id: number;
name: string;
email: string;
role: "admin" | "user" | "guest";
nickname?: string; // optional — may or may not exist
}
// Type alias (same idea, different syntax)
type User = {
id: number;
name: string;
email: string;
};
Using them:
function greetUser(user: User): string {
return `Hello, ${user.name}`;
}
// TypeScript catches this at compile time:
greetUser({ id: 1 }); // ❌ Error: missing 'name' and 'email'
greetUser({ id: 1, name: "Cam", email: "cam@email.com" }); // ✅
4. The Problem TypeScript Can't Solve
TypeScript protects you from your own mistakes in code you write. It cannot protect you from data that comes from the outside world.
interface User {
id: number;
name: string;
email: string;
}
async function getUser() {
const res = await fetch("/api/user");
const data = await res.json(); // typed as `any` — TypeScript has no idea what this is
const user = data as User; // you're just TELLING TypeScript "trust me"
// no actual check happens here
console.log(user.email.toUpperCase()); // 💥 crashes if email is missing or null
// TypeScript had no way to stop this
}
as Useris a type cast — you're overriding TypeScript's judgment. It trusts you. But if the API returns something unexpected, your app crashes at runtime.
TypeScript protects the boundary within your code. It cannot protect the boundary between your code and the outside world.
II. Zod
1. What is Zod?
- Zod is a runtime validation library for TypeScript
- It lets you define the shape of your data once, then use it for both:
- Runtime validation — actually checking real data as it arrives
- TypeScript type inference — getting TS types for free via
z.infer
- It's a regular JavaScript library — it exists and runs at runtime, unlike TS types
TypeScript types → compile-time only, erased at runtime
Zod schemas → runtime JS, actually checks your data when it arrives
2. Defining a Schema
import { z } from "zod";
const UserSchema = z.object({
id: z.number(),
name: z.string(),
email: z.string().email(),
role: z.enum(["admin", "user", "guest"]),
nickname: z.string().optional(), // may or may not exist
});
3. Inferring a TypeScript Type from a Schema
Instead of writing the interface separately and keeping two things in sync, use z.infer:
// Without z.infer — you write both and must keep them in sync manually:
const UserSchema = z.object({ id: z.number(), name: z.string() });
interface User { id: number; name: string; } // duplicate — error-prone!
// With z.infer — one source of truth:
const UserSchema = z.object({ id: z.number(), name: z.string() });
type User = z.infer<typeof UserSchema>;
// User is now automatically: { id: number; name: string }
z.inferuses TypeScript's type system to extract a type from your Zod schema. The schema becomes your single source of truth for both validation and types. Write one thing — get both a runtime validator and a compile-time type.
4. Parsing Data
.parse() — throws on failure
const user = UserSchema.parse(data); // throws a ZodError if data doesn't match
console.log(user.email); // safe to use — Zod confirmed it exists and is a string
Use when you want the error to bubble up (e.g. caught by your global error handler).
.safeParse() — never throws, returns a result object
const result = UserSchema.safeParse(data);
if (result.success) {
console.log(result.data.email); // typed and validated
} else {
console.log(result.error.issues);
// e.g. [{ path: ["email"], message: "Invalid email" }]
}
Use when you want to handle the error gracefully in place — show a message, retry, log it.
.parse()— throws on failure, use when you want the error to bubble up.safeParse()— never throws, returns a result object you check yourself, use when you want to handle the error in place
5. Side-by-Side: Without vs With Zod
Without Zod:
interface User {
id: number;
name: string;
email: string;
}
async function getUser() {
const res = await fetch("/api/user");
const data = await res.json();
const user = data as User; // no real check — TypeScript just trusts you
console.log(user.email.toUpperCase()); // 💥 crashes if email is missing
}
With Zod:
import { z } from "zod";
const UserSchema = z.object({
id: z.number(),
name: z.string(),
email: z.string().email(),
});
type User = z.infer<typeof UserSchema>;
async function getUser() {
const res = await fetch("/api/user");
const data = await res.json();
const user = UserSchema.parse(data); // ✅ actually checks the shape at runtime
// throws before we ever touch bad data
console.log(user.email.toUpperCase()); // safe — Zod confirmed email exists
}
If the API returns { id: 1, name: "Cam" } with no email, Zod throws immediately:
ZodError: [
{ path: ["email"], message: "Required" }
]
console.log is never reached — the error is caught before any bad data is used.
6. Zod Primitives — Quick Reference
z.string() // string
z.string().min(1) // non-empty string
z.string().max(100) // max length
z.string().email() // must be valid email format
z.string().url() // must be valid URL
z.string().regex(/^\d+$/) // must match regex
z.number() // number
z.number().int() // must be integer
z.number().min(0) // minimum value
z.number().max(100) // maximum value
z.number().positive() // must be > 0
z.boolean() // true or false
z.literal("exact") // must be exactly this value
z.enum(["a", "b", "c"]) // must be one of these exact strings
z.array(z.string()) // string[]
z.array(z.number()).min(1) // at least one number in the array
z.object({ key: z.string() }) // object with a defined shape
z.string().optional() // string | undefined — field may not exist
z.string().nullable() // string | null — field exists but can be null
z.string().nullish() // string | null | undefined — either/both
z.union([z.string(), z.number()]) // string | number
z.record(z.string()) // { [key: string]: string }
7. Common Use Cases
Validating API responses
const PostSchema = z.object({
id: z.number(),
title: z.string(),
body: z.string(),
});
type Post = z.infer<typeof PostSchema>;
const post = PostSchema.parse(
await fetch("/api/post/1").then(r => r.json())
);
// post is now typed and validated as { id: number; title: string; body: string }
Validating request body in Express
const CreatePostSchema = z.object({
title: z.string().min(1),
body: z.string().min(1),
authorId: z.number().int().positive(),
});
app.post("/posts", (req, res) => {
const result = CreatePostSchema.safeParse(req.body);
if (!result.success) {
return res.status(400).json({ errors: result.error.issues });
}
const { title, body, authorId } = result.data; // typed and safe
// ... create the post
});
Validating environment variables
const EnvSchema = z.object({
DATABASE_URL: z.string().url(),
PORT: z.string().regex(/^\d+$/),
SECRET_KEY: z.string().min(1),
});
const env = EnvSchema.parse(process.env);
// crashes immediately on startup if anything is missing or wrong
// better than crashing later in production when the variable is actually used
Reusable validation middleware in Express
function validateRequest(schema: z.ZodSchema) {
return (req, res, next) => {
const result = schema.safeParse(req.body);
if (!result.success) {
return res.status(400).json({ errors: result.error.issues });
}
req.body = result.data; // replace with typed, parsed, trimmed value
next();
};
}
// Used on routes — controller never needs to re-validate:
router.post(
"/posts",
validateRequest(CreatePostSchema),
createPost
);
Form validation with React Hook Form
import { zodResolver } from "@hookform/resolvers/zod";
const FormSchema = z.object({
email: z.string().email("Please enter a valid email"),
password: z.string().min(8, "Password must be at least 8 characters"),
});
type FormData = z.infer<typeof FormSchema>;
const { register, handleSubmit } = useForm<FormData>({
resolver: zodResolver(FormSchema), // Zod handles all validation
});
III. TypeScript + Zod Together — The Full Picture
┌──────────────────────────────────────────────┐
│ COMPILE TIME (TypeScript) │
│ Catches typos, wrong types in your own │
│ code — editor warnings, build errors. │
│ Gone completely at runtime. │
└──────────────────────────────────────────────┘
┌──────────────────────────────────────────────┐
│ RUNTIME (Zod) │
│ Validates data from the outside world — │
│ API responses, form input, env vars. │
│ TypeScript cannot help here. │
└──────────────────────────────────────────────┘
TypeScript guards the boundary within your code — your own functions, your own logic. Zod guards the boundary between your code and the outside world — APIs, users, databases.
They solve different problems. Used together, you get full coverage:
- TypeScript ensures you use the data correctly once you have it
- Zod ensures the data is actually what you expect before you touch it
The key insight: write one Zod schema → get a TypeScript type and a runtime validator for free. No duplication. No keeping two things in sync. One source of truth.