TL;DR
Learn Zod for runtime schema validation in TypeScript. Parse untrusted data safely and infer types from schemas automatically.
Key concepts
- Zod TypeScript
- runtime validation TypeScript
- schema validation
- Zod tutorial
Zod And Validation
TypeScript's type system disappears at runtime. A variable typed as string might arrive from an API as null. A number field might be a string that happens to look numeric. TypeScript trusts you — but data from the outside world deserves no trust at all.
Zod is a TypeScript-first schema validation library that bridges this gap. You define a schema once, use it to validate and parse data at runtime, and get TypeScript types inferred automatically — no duplication, no drift between your types and your validation logic. This lesson teaches Zod's API through prose and reads its schemas closely, and it teaches the underlying discipline — parse untrusted input into typed values — through code you can actually run here.
How this lesson runs
Two kinds of code fence appear below, and the difference matters:
- Runnable fences (
typescript playground) contain hand-rolled parse-and-guard functions — plain TypeScript with no dependencies. The runner strips the types and executes the JavaScript, so these run right here on the page. They are the same idea Zod automates, built by hand so you can see the machinery. - Zod fences are marked
typescript no-run. Zod is an npm package, and the runner has nonode_modules— it cannotimport { z } from "zod", so these fences would crash withCannot find module 'zod'if run. They are here to teach Zod's real API and are honest about not executing in this sandbox. In a real project (npm install zod) they run exactly as written.
Keep that split in mind: when you see no-run, you are reading Zod; when you see playground, you are running the hand-rolled equivalent.
Why Runtime Validation Matters
Consider this common mistake:
// TypeScript is happy — but this blows up at runtime.
interface User {
id: number;
name: string;
email: string;
}
const rawData: unknown = JSON.parse('{"id": "not-a-number", "name": null}');
const user = rawData as User;
// TypeScript thinks these are fine — the `as` cast told it to trust us.
// The runner strips types and runs anyway, so the mismatch surfaces at run time:
try {
console.log(user.id.toFixed(2));
} catch (err) {
console.log("Crash on user.id.toFixed:", (err as Error).message);
}
try {
console.log(user.name.toUpperCase());
} catch (err) {
console.log("Crash on user.name.toUpperCase:", (err as Error).message);
}
The as cast tells TypeScript to trust you — but the data doesn't match. Because the runner strips types, nothing stops the code, and user.id (really the string "not-a-number") has no .toFixed, while user.name is null. This is where validation comes in: check the shape and types of your data before you use it.
Predict
A JSON payload arrives with a string id and a string balance, but it is cast as Account (numbers expected). No validation. This exact code is checked with tsc --strict AND run by the tsx runner. Predict BOTH: does tsc report an error, and what does the runner do at account.balance.toFixed(2)?
interface Account {
id: number;
balance: number;
}
const raw: unknown = JSON.parse('{"id": "A-1", "balance": "100"}');
const account = raw as Account;
console.log(account.balance.toFixed(2));Defining Schemas and Parsing Data
A Zod schema describes the structure and types your data must match. The .parse() method throws if validation fails; .safeParse() returns a result object instead. The next fence is Zod itself, so it is no-run — read it as the real API, then run the hand-rolled equivalent below it.
Version note: This lesson uses Zod 3's string-method API —
z.string().email(),.uuid(),.datetime(). Zod 4 moves these to top-level forms (z.email(),z.uuid(),z.iso.datetime()) and deprecates the chained versions. The concepts are identical; only the spelling changed.
import { z } from "zod";
const UserSchema = z.object({
id: z.number(),
name: z.string().min(1),
email: z.string().email(),
age: z.number().int().min(0).max(120).optional(),
});
// Successful parse
const goodData = { id: 1, name: "Alice", email: "alice@example.com" };
const user = UserSchema.parse(goodData);
console.log("Parsed user:", user.name, user.email);
// Safe parse — no exception on failure
const badData = { id: "oops", name: "", email: "not-an-email" };
const result = UserSchema.safeParse(badData);
if (!result.success) {
result.error.issues.forEach((issue) => {
console.log(`${issue.path.join(".")}: ${issue.message}`);
});
} else {
console.log("Valid:", result.data);
}
Zod primitives like z.string(), z.number(), and z.boolean() chain with validators: .min(), .max(), .email(), .url(), .int(), and many more. Each returns a new schema, making them composable. Notice the two entry points: .parse() throws on bad data, while .safeParse() returns { success: false, error } — safer for user-facing input where a throw would be a crash.
Under the hood, a Zod schema is just a function that takes unknown and returns a typed result. You can build that same shape by hand — and this one runs:
// A hand-rolled "schema": unknown in, a typed Result out — the same idea Zod
// automates. This runs here because it has no dependencies.
type ParseResult<T> = { ok: true; value: T } | { ok: false; error: string };
interface User {
id: number;
name: string;
email: string;
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function parseUser(input: unknown): ParseResult<User> {
if (!isRecord(input)) return { ok: false, error: "expected an object" };
if (typeof input.id !== "number") return { ok: false, error: "id must be a number" };
if (typeof input.name !== "string" || input.name.length < 1) {
return { ok: false, error: "name must be a non-empty string" };
}
if (typeof input.email !== "string" || !input.email.includes("@")) {
return { ok: false, error: "email must contain @" };
}
return { ok: true, value: { id: input.id, name: input.name, email: input.email } };
}
// The safe path: a failure is a value, not a thrown exception.
const good = parseUser({ id: 1, name: "Alice", email: "alice@example.com" });
const bad = parseUser({ id: "oops", name: "", email: "not-an-email" });
console.log("good:", good.ok ? good.value.name : good.error);
console.log("bad:", bad.ok ? bad.value.name : bad.error);
The isRecord guard is a type predicate (value is Record<string, unknown>), the same tool you met in 17-type-narrowing: once it returns true, the compiler lets you index input by key, and each typeof check narrows one field from unknown before it lands in the returned User. That is exactly what Zod does internally — it just generates the checks from your schema instead of making you write them.
Recall
Without scrolling up: parseUser returns ParseResult<T> = { ok: true; value: T } | { ok: false; error: string } instead of throwing. You met this exact shape in 09-error-handling. What was it called there, and why is returning it preferable to throwing when validating untrusted input?
Inferring TypeScript Types
One of Zod's best features: you define the schema once and derive the TypeScript type from it automatically using z.infer. No more keeping an interface and a validator in sync. This is Zod, so it is no-run:
import { z } from "zod";
const ProductSchema = z.object({
id: z.string().uuid(),
name: z.string().min(1).max(100),
price: z.number().positive(),
category: z.enum(["electronics", "clothing", "food"]),
tags: z.array(z.string()).default([]),
createdAt: z.string().datetime(),
});
// Infer the TypeScript type directly from the schema — no separate interface.
type Product = z.infer<typeof ProductSchema>;
function displayProduct(product: Product): void {
console.log(`${product.name} — $${product.price.toFixed(2)}`);
console.log(`Tags: ${product.tags.join(", ") || "none"}`);
}
const product = ProductSchema.parse({
id: "123e4567-e89b-12d3-a456-426614174000",
name: "Wireless Headphones",
price: 79.99,
category: "electronics",
createdAt: "2024-01-15T10:30:00Z",
});
displayProduct(product);
type Product = z.infer<typeof ProductSchema> is the single most important line: the type is derived from the schema, so they can never drift apart. Note tags has .default([]) — Zod fills in the default when the field is absent, and the inferred type reflects this as string[] rather than string[] | undefined. (The machinery that lets z.infer walk a schema's keys and produce a type is mapped types, which is the next lesson after the capstone.)
Transformations and Refinements
Schemas can transform data during parsing, and refinements add custom validation that built-in methods don't cover. Still Zod, still no-run:
import { z } from "zod";
// Transform: coerce and clean input data as it is parsed.
const SearchParamsSchema = z.object({
query: z.string().trim().toLowerCase(),
page: z
.string()
.transform((val) => parseInt(val, 10))
.pipe(z.number().int().positive()),
});
const params = SearchParamsSchema.parse({ query: " TypeScript ", page: "3" });
console.log(params); // { query: "typescript", page: 3 }
// Refinement: custom business logic the built-ins can't express.
const PasswordSchema = z
.string()
.min(8, "Must be at least 8 characters")
.refine((val) => /[A-Z]/.test(val), "Must contain an uppercase letter")
.refine((val) => /[0-9]/.test(val), "Must contain a number");
const result = PasswordSchema.safeParse("short");
console.log(result.success ? "valid" : result.error.issues[0].message);
.transform() converts raw input (strings from query params, form fields) into the types your app needs; .refine() attaches a predicate with its own error message. Both are things our hand-rolled parser would express as extra if checks and reassignments — Zod just makes them declarative. The runnable equivalent below refines a value (an integer in range) and transforms input (a trimmed, lowercased string) by hand, and it runs:
// Refinement + transform by hand — the same effect as .refine() / .transform().
type ParseResult<T> = { ok: true; value: T } | { ok: false; error: string };
// Transform: trim and lowercase a string as we parse it.
function parseQuery(value: unknown): ParseResult<string> {
if (typeof value !== "string") return { ok: false, error: "query must be a string" };
return { ok: true, value: value.trim().toLowerCase() };
}
// Refine: a page number must parse to a positive integer.
function parsePage(value: unknown): ParseResult<number> {
if (typeof value !== "string") return { ok: false, error: "page must be a string" };
const n = parseInt(value, 10);
if (!Number.isInteger(n) || n < 1) return { ok: false, error: "page must be a positive integer" };
return { ok: true, value: n };
}
const query = parseQuery(" TypeScript ");
const page = parsePage("3");
const badPage = parsePage("0");
console.log("query:", query.ok ? query.value : query.error);
console.log("page:", page.ok ? page.value : page.error);
console.log("badPage:", badPage.ok ? badPage.value : badPage.error);
The transform (value.trim().toLowerCase()) reshapes the value as it crosses the boundary; the refinement (Number.isInteger(n) && n >= 1) rejects values that pass the type check but fail the business rule. Zod bundles both into the schema so the parse and the transform happen in one pass — but the shape is identical to what you wrote here.
Composing Schemas
Real data structures are nested. Zod schemas compose — any schema can be a field inside another. One more Zod no-run:
import { z } from "zod";
const AddressSchema = z.object({
city: z.string(),
country: z.string().length(2, "Use ISO 3166-1 alpha-2 country codes"),
});
const OrderSchema = z.object({
orderId: z.string(),
customer: z.object({ name: z.string(), email: z.string().email() }),
shippingAddress: AddressSchema, // a schema reused as a field
items: z
.array(z.object({ productId: z.string(), quantity: z.number().int().positive() }))
.min(1, "Order must have at least one item"),
});
type Order = z.infer<typeof OrderSchema>;
const order = OrderSchema.safeParse({
orderId: "ORD-001",
customer: { name: "Bob", email: "bob@example.com" },
shippingAddress: { city: "Amsterdam", country: "NL" },
items: [{ productId: "P-42", quantity: 2 }],
});
if (order.success) {
console.log(`Order for ${order.data.customer.name}: ${order.data.items.length} item(s)`);
}
Reusing AddressSchema in multiple places means validation logic is defined once and enforced everywhere. Hand-rolled parsers compose the same way — a parseAddress function called from inside parseOrder, each returning its own ParseResult. The build below is where you put all of this together.
Arrange the code
Reassemble a tiny validation pipeline: a parseName validator (a const arrow that returns a trimmed name, or guest when the input is too short or not a string), apply it to a raw value, derive a verdict, format a line, and log it. The lines are shuffled. Each const consumes the binding above it, so only one order runs top-to-bottom and logs name=Ada (ok).
const result = parseName(" Ada ");console.log(line);const parseName = (v: unknown): string => (typeof v === "string" && v.length >= 3 ? v.trim() : "guest");const line = `name=${result} (${verdict})`;const verdict = result === "guest" ? "rejected" : "ok";
Try It Yourself
Reading about schemas is not the same as writing one. This is a build task: a small program that reports its own pass/fail. You will build a hand-rolled registration validator — the runnable stand-in for a Zod schema — that parses unknown input into a typed Registration. Three functions are stubbed. Run it as-is and it fails immediately, naming the first stub. Implement each until every check passes and it prints All checks passed.
Build
Finish the build. Three functions are stubbed and the checks below them fail until each returns the right value. Run it as-is to see which check fails first, decide what that function is missing, then implement the three functions until it prints 'All checks passed.' The checks run top to bottom, so the first failure you see is TODO 1 — implement it first, then work down.
import assert from "node:assert";
// A hand-rolled schema layer — the runnable stand-in for a Zod schema (the runner
// has no npm packages, so Zod itself can't run here). A "schema" is just a function:
// unknown in, a typed Result out. Do NOT change these.
type ParseResult<T> = { ok: true; value: T } | { ok: false; error: string };
interface Registration {
username: string;
email: string;
age: number;
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
// TODO 1: validate a required string field of at least `min` characters.
// Return { ok: true, value } when it is a string of length >= min, else
// { ok: false, error } naming the field. A number or missing value must fail.
// parseString("name", "alice", 3).ok -> true
// parseString("name", "ab", 3).ok -> false
// parseString("name", 42, 3).ok -> false
function parseString(field: string, value: unknown, min: number): ParseResult<string> {
// your code here
return { ok: false, error: "not implemented" };
}
// TODO 2: validate a required non-negative integer. Reject non-numbers, negatives,
// and non-integers (e.g. 3.5). A provided 0 is VALID — use Number.isInteger, not a
// truthiness check, so 0 is kept (the same "missing vs falsy" care validation needs).
// parseAge(0).ok -> true parseAge(-1).ok -> false parseAge("5").ok -> false
function parseAge(value: unknown): ParseResult<number> {
// your code here
return { ok: false, error: "not implemented" };
}
// TODO 3: parse a whole Registration from unknown input. Guard it is a record, then
// run each field parser IN ORDER (username min 3, email min 3, age); return the
// FIRST field error you hit, otherwise the built Registration.
// parseRegistration({ username:"alice", email:"a@b.co", age:30 }).ok -> true
function parseRegistration(input: unknown): ParseResult<Registration> {
// your code here
return { ok: false, error: "not implemented" };
}
// --- Build checks: these must all pass. Do not edit below this line. ---
assert.strictEqual(parseString("username", "alice", 3).ok, true, "TODO 1: a long-enough string should pass");
assert.strictEqual(parseString("username", "ab", 3).ok, false, "TODO 1: a too-short string should fail");
assert.strictEqual(parseString("username", 42, 3).ok, false, "TODO 1: a non-string should fail");
assert.strictEqual(parseAge(0).ok, true, "TODO 2: a provided 0 is a valid age");
assert.strictEqual(parseAge(-1).ok, false, "TODO 2: a negative age should fail");
assert.strictEqual(parseAge(3.5).ok, false, "TODO 2: a non-integer age should fail");
assert.strictEqual(parseAge("5").ok, false, "TODO 2: a numeric string is not a number");
const good = parseRegistration({ username: "alice", email: "a@b.co", age: 30 });
assert.strictEqual(good.ok, true, "TODO 3: a well-formed registration should parse");
assert.strictEqual(
parseRegistration({ username: "al", email: "a@b.co", age: 30 }).ok,
false,
"TODO 3: a too-short username should fail the whole parse",
);
assert.strictEqual(
parseRegistration({ username: "alice", email: "x", age: 30 }).ok,
false,
"TODO 3: a too-short email should fail the whole parse (email must be validated too)",
);
const missingAge = parseRegistration({ username: "alice", email: "a@b.co" });
assert.strictEqual(missingAge.ok, false, "TODO 3: a missing age should fail the whole parse");
console.log("All checks passed.");
console.log("Good:", good.value.username);
const bad = parseRegistration({ username: "al", email: "a@b.co", age: 30 });
console.log("Bad:", bad.ok ? "parsed" : "rejected");Expected output: All checks passed.
Good: alice
Bad: rejected
Once it passes, try two variations and predict each before running:
- Drop the integer check. In
parseAge, remove!Number.isInteger(value) ||so the guard is justtypeof value !== "number" || value < 0. Predict which check fails first before running.parseAge(0)still passes andparseAge(-1)still fails, butparseAge(3.5)now passes — so the first check to break is TODO 2's non-integer case, which fires withAssertionError: TODO 2: a non-integer age should fail. A schema that forgets.int()accepts3.5where you meant a whole number — the exact gapz.number().int()closes. - Reject zero as falsy. In
parseAge, changevalue < 0tovalue <= 0. Predict which check fails first before running. NowparseAge(0)is rejected, so the very first age check — TODO 2'sparseAge(0).ok === true— fails withAssertionError: TODO 2: a provided 0 is a valid age. This is the "missing vs falsy" trap in validation: treating a legitimate0as invalid, the runtime bug a truthiness check silently introduces.
Capstone milestone
Milestone — the validation layer. A hand-rolled parser takes unknown input and returns a ParseResult: it guards the record shape, narrows every field through a check before trusting it, and reports the first failure as a value rather than throwing. Past this boundary, everything is a real typed value. In production this is a Zod schema; here you built it by hand so you know what the schema automates.
Hint: This is the validation-layer milestone, shared with 09-error-handling (the Result pattern) — and it is REQUIRED for the capstone. The capstone's parseActivity is this same hand-rolled schema over the Activity union; in a real project you would express it as a Zod z.discriminatedUnion instead, but the parse-don't-validate discipline is identical.
- A ParseResult / Result discriminated union carries success or the first failure as a value (no throw)
- An isRecord type predicate guards the object shape before any field is indexed
- Each field is narrowed from unknown (typeof / Number.isInteger) before it lands in the returned value
- A provided 0 stays valid — 'missing' and 'falsy' are treated as different, not collapsed by a truthiness check
Key Takeaways
- TypeScript types are erased at runtime — an
ascast silences the compiler without checking anything, so untrusted data must be validated against its actual shape - A schema is really a function:
unknownin, a typed result out — Zod generates that function from a declaration; a hand-rolled parser writes it out .parse()throws on invalid data;.safeParse()returns a{ success, data }/{ success, error }result — the Result pattern from 09-error-handling, safer for user-facing inputz.infer<typeof Schema>derives the TypeScript type from the schema, so runtime validation and compile-time types can never drift (the machinery is mapped types, coming after the capstone).transform()reshapes input as it is parsed;.refine()adds business-rule checks the built-ins can't express — both are extra steps a hand-rolled parser performs explicitly- Schemas compose: a schema (or
parseXfunction) used as a field inside a larger one, validation defined once and reused - Treat "missing" and "falsy" as different: a legitimate
0or""must survive validation, which a truthiness check would wrongly reject
Pro Tip: In a real project, reach for Zod (or a peer like Valibot) rather than hand-rolling every guard — define the schema and export both it and its inferred type from one file (
export const UserSchemaandexport type User = z.infer<typeof UserSchema>) so runtime validation and compile-time types share a single source of truth. Hand-rolling it once, as you did above, is how you understand what that one import is doing for you — and it is exactly the parser the capstone builds, because that sandbox has nonode_moduleseither.
Next Steps
You can now validate untrusted input at the boundary and get typed values past it. The most common place that boundary lives is a network call — an API response is the untyped data you least control. Next, you'll build type-safe API clients: generic fetch wrappers, discriminated-union results, and runtime guards that validate a response before your code trusts it.
Ready to continue? Head to Type-Safe APIs!
Two-tier handoff: this document is the complete reading surface. Continue learning for stateful practice, progress, and real sandbox execution.