|$ curl https://forge-ai.dev/api/markdown?path=docs/typescript/zod
$cat docs/zod-+-typescript.md
updated Today·20 min read·published

Zod + TypeScript

TypeScriptValidationRuntimeIntermediate🎯Free Tools
Introduction

TypeScript types are erased at runtime. JSON from the network, form data, and env vars arrive as unknown. Zod (and similar libraries) give you runtime schemas whose TypeScript types are inferred — one source of truth.

This page covers production patterns: parse vs safeParse, transforms, refinements, branded output, and keeping schemas in sync with domain types.

Why Runtime Validation
BoundaryRisk without validationWith Zod
HTTP JSON bodyany-shaped objectsparse → typed value or error
process.envstring | undefined everywherecoerced typed config
localStoragestale/corrupt datasafeParse + fallback
Form valuesstringly typedvalidated domain object

info

Compile-time types protect your code from yourself. Runtime schemas protect your code from the world.
Schemas & z.infer
basics.ts
TypeScript
1import { z } from "zod";
2
3const UserSchema = z.object({
4 id: z.string().uuid(),
5 email: z.string().email(),
6 age: z.number().int().min(0).max(150),
7 role: z.enum(["user", "admin"]).default("user"),
8});
9
10type User = z.infer<typeof UserSchema>;
11// { id: string; email: string; age: number; role: "user" | "admin" }
12
13const parsed = UserSchema.parse(JSON.parse(raw)); // throws ZodError
14const result = UserSchema.safeParse(data);
15if (result.success) {
16 result.data; // User
17} else {
18 result.error.flatten();
19}

best practice

Export the schema and type X = z.infer<typeof XSchema>. Do not duplicate a hand-written interface that can drift.
parse vs safeParse
APIOn failureUse when
parsethrows ZodErrorTrusted internal after boundary; scripts
safeParsereturns { success }HTTP handlers, UI forms
parseAsyncasync refinementsDB uniqueness checks
parse.ts
TypeScript
1export function parseUser(input: unknown): User {
2 return UserSchema.parse(input);
3}
4
5export function tryUser(input: unknown): User | null {
6 const r = UserSchema.safeParse(input);
7 return r.success ? r.data : null;
8}
Objects, Arrays, Unions, Discriminated
composites.ts
TypeScript
1const Tag = z.object({ name: z.string(), color: z.string() });
2const Post = z.object({
3 title: z.string().min(1),
4 tags: z.array(Tag).max(10),
5 status: z.union([z.literal("draft"), z.literal("published")]),
6});
7
8const Event = z.discriminatedUnion("type", [
9 z.object({ type: z.literal("click"), x: z.number(), y: z.number() }),
10 z.object({ type: z.literal("key"), key: z.string() }),
11]);
12type Event = z.infer<typeof Event>;
Transforms, Pipes & Refinements
transforms.ts
TypeScript
1const DateString = z.string().datetime().transform((s) => new Date(s));
2
3const Password = z
4 .string()
5 .min(8)
6 .refine((s) => /[A-Z]/.test(s), { message: "Need uppercase" });
7
8const Form = z
9 .object({
10 password: z.string(),
11 confirm: z.string(),
12 })
13 .refine((d) => d.password === d.confirm, {
14 message: "Passwords must match",
15 path: ["confirm"],
16 });
17
18const CoercedAge = z.coerce.number().int().positive();
📝

note

z.input<typeof Schema> vs z.output<typeof Schema> matter when transforms change the type.
Branded Types with Zod
branded.ts
TypeScript
1const UserId = z.string().uuid().brand<"UserId">();
2type UserId = z.infer<typeof UserId>;
3
4function loadUser(id: UserId) {}
5const id = UserId.parse(req.params.id);
6loadUser(id);
7// loadUser("not-branded"); // Error
API Boundary Pattern
api.ts
TypeScript
1import { z } from "zod";
2
3const CreatePostInput = z.object({
4 title: z.string().min(1).max(120),
5 body: z.string().min(1),
6});
7export type CreatePostInput = z.infer<typeof CreatePostInput>;
8
9export async function POST(req: Request) {
10 const json: unknown = await req.json();
11 const parsed = CreatePostInput.safeParse(json);
12 if (!parsed.success) {
13 return Response.json(parsed.error.flatten(), { status: 400 });
14 }
15 const input = parsed.data; // CreatePostInput
16 // ... persist
17 return Response.json({ ok: true });
18}
Env Config Pattern
env.ts
TypeScript
1const Env = z.object({
2 NODE_ENV: z.enum(["development", "test", "production"]),
3 DATABASE_URL: z.string().url(),
4 PORT: z.coerce.number().default(3000),
5});
6
7export const env = Env.parse(process.env);
Schemas vs Hand-Written Types
ApproachProsCons
Zod first + z.inferSingle source, runtime safeLibrary dependency
Types first + Zod mirrorsTypes feel primaryDrift risk
Types onlyZero runtime costUnsafe at boundaries

best practice

Zod-first at every trust boundary. Pure internal domain code can stay types-only.
Error Handling UX
errors.ts
TypeScript
1const r = UserSchema.safeParse(data);
2if (!r.success) {
3 const fieldErrors = r.error.flatten().fieldErrors;
4 // { email?: string[]; age?: string[] }
5 return fieldErrors;
6}
Testing Schemas
test.ts
TypeScript
1import { expect, test } from "vitest";
2
3test("accepts valid user", () => {
4 expect(() =>
5 UserSchema.parse({
6 id: "550e8400-e29b-41d4-a716-446655440000",
7 email: "a@b.c",
8 age: 30,
9 }),
10 ).not.toThrow();
11});
12
13test("rejects bad email", () => {
14 const r = UserSchema.safeParse({ id: "x", email: "nope", age: 1 });
15 expect(r.success).toBe(false);
16});
More Production Patterns

Pagination query

page-query.ts
TypeScript
1const PageQuery = z.object({
2 page: z.coerce.number().int().min(1).default(1),
3 pageSize: z.coerce.number().int().min(1).max(100).default(20),
4 q: z.string().trim().optional(),
5});
6type PageQuery = z.infer<typeof PageQuery>;
7
8export function parseQuery(url: URL) {
9 return PageQuery.parse(Object.fromEntries(url.searchParams));
10}

Recursive schemas

recursive.ts
TypeScript
1type Category = {
2 name: string;
3 children: Category[];
4};
5
6const CategorySchema: z.ZodType<Category> = z.lazy(() =>
7 z.object({
8 name: z.string(),
9 children: z.array(CategorySchema),
10 }),
11);

Shared schema modules

modules.ts
TypeScript
1// schemas/user.ts
2export const UserSchema = z.object({
3 id: z.string().uuid(),
4 email: z.string().email(),
5});
6export type User = z.infer<typeof UserSchema>;
7
8// schemas/post.ts
9export const PostSchema = z.object({
10 id: z.string().uuid(),
11 author: UserSchema,
12 title: z.string(),
13});
14export type Post = z.infer<typeof PostSchema>;

OpenAPI-ish description

describe.ts
TypeScript
1const Product = z
2 .object({
3 sku: z.string().describe("Stock keeping unit"),
4 priceCents: z.number().int().nonnegative(),
5 })
6 .describe("Sellable product");
Interop with Class / Interface Types

When a legacy interface exists, bridge carefully:

legacy.ts
TypeScript
1interface LegacyUser {
2 id: string;
3 email: string;
4}
5
6const LegacyUserSchema: z.ZodType<LegacyUser> = z.object({
7 id: z.string(),
8 email: z.string().email(),
9});
10
11// Prefer migrating callers to z.infer over maintaining both long-term

warning

z.ZodType<T> annotations can widen and reduce inference quality — use sparingly.
Performance Notes
TipDetail
Reuse schemasDo not recreate inside hot loops
safeParse in handlersAvoid try/catch parse for control flow
Strip unknown keys.strict() vs default strip
Lazy recursionOnly where needed
strict-schema.ts
TypeScript
1const StrictUser = UserSchema.strict(); // reject unknown keys
2const StripUser = UserSchema; // default: strip unknown
Effects Pipeline
untitled.typescript
TypeScript
1const TrimmedEmail = z
2 .string()
3 .transform((s) => s.trim())
4 .pipe(z.string().email());
5
6const Percent = z
7 .number()
8 .transform((n) => Math.round(n))
9 .pipe(z.number().min(0).max(100));

Input type of a piped schema may differ from output — use z.input / z.output.

Multi-step Form Schemas
untitled.typescript
TypeScript
1const Step1 = z.object({ kind: z.literal("email"), email: z.string().email() });
2const Step2 = z.object({ kind: z.literal("sms"), phone: z.string().min(8) });
3const Contact = z.discriminatedUnion("kind", [Step1, Step2]);
Worked Examples — zod

End-to-end snippets for zod. Type these into a strict project and mutate them until the checker teaches you the edges.

Example A

untitled.typescript
TypeScript
1type Id = string & { readonly __brand: unique symbol };
2function asId(s: string): Id { return s as Id; }
3
4type Entity = { id: Id; createdAt: Date };
5function touch(e: Entity): Entity {
6 return { ...e, createdAt: new Date() };
7}

Example B — conditional distribute

untitled.typescript
TypeScript
1type IsString<T> = T extends string ? true : false;
2type A = IsString<"hi" | 1>; // true | false (distributed)
3
4type IsStringBox<T> = [T] extends [string] ? true : false;
5type B = IsStringBox<"hi" | 1>; // false

Example C — key remapping filter

untitled.typescript
TypeScript
1type OnlyStrings<T> = {
2 [K in keyof T as T[K] extends string ? K : never]: T[K];
3};
4type User = { id: string; age: number; name: string };
5type Names = OnlyStrings<User>; // { id: string; name: string }

Example D — template route params

untitled.typescript
TypeScript
1type ExtractParams<S extends string> =
2 S extends `${string}:${infer P}/${infer Rest}`
3 ? P | ExtractParams<Rest>
4 : S extends `${string}:${infer P}`
5 ? P
6 : never;
7type Params = ExtractParams<"/users/:id/posts/:postId">; // "id" | "postId"

info

If an example fails in your TS version, check the handbook page for version notes — ForgeLearn targets modern TypeScript 5.x.
Self-Check Questions

Answer without looking. Then verify in the playground / tsc.

#Question
1What is the difference between annotation, assertion, and satisfies?
2When does a conditional type distribute over a union?
3How do you prove exhaustiveness for a discriminated union?
4Why is unknown safer than any at JSON boundaries?
5What does noUncheckedIndexedAccess change about obj[key]?
6How do you preserve literal types through a generic helper?
7When should you prefer interface over type (and vice versa)?
8How does z.infer keep runtime and types aligned?

best practice

Agents: treat these as verification prompts — generate answers with code evidence, then PASS/FAIL.
Agent Rules for Zod

When generating APIs: always safeParse unknown input; export z.infer types; never hand-write a duplicate interface.

untitled.bash
Bash
1curl -s "https://forgelearn.dev/api/markdown?path=typescript/zod"
Extended Drills (zod) — Set 1

Extended reference material for deliberate practice. Work through each snippet under strict: true and note where inference surprises you.

Snippet 1 — deep-readonly

Practice snippet 1. Predict the resulting type, then confirm with hover types in your editor.

deep-readonly-1.ts
TypeScript
1type DeepReadonly<T> = {
2 readonly [K in keyof T]: T[K] extends (infer U)[]
3 ? readonly DeepReadonly<U>[]
4 : T[K] extends object
5 ? DeepReadonly<T[K]>
6 : T[K];
7};

Snippet 2 — uti

Practice snippet 2. Predict the resulting type, then confirm with hover types in your editor.

uti-2.ts
TypeScript
1type UnionToIntersection<U> =
2 (U extends unknown ? (k: U) => void : never) extends (k: infer I) => void ? I : never;
3type I = UnionToIntersection<{ a: 1 } | { b: 2 }>; // { a:1 } & { b:2 }

Snippet 3 — narrow-key

Practice snippet 3. Predict the resulting type, then confirm with hover types in your editor.

narrow-key-3.ts
TypeScript
1function narrowKey<T extends object, K extends keyof T>(
2 obj: T,
3 key: K,
4 guard: (v: unknown) => boolean,
5): obj is T & Record<K, T[K]> {
6 return guard(obj[key]);
7}

Snippet 4 — as-tuple

Practice snippet 4. Predict the resulting type, then confirm with hover types in your editor.

as-tuple-4.ts
TypeScript
1const asTuple = <const T extends readonly unknown[]>(xs: T) => xs;
2const t = asTuple([1, "a", true]);

Snippet 5 — json

Practice snippet 5. Predict the resulting type, then confirm with hover types in your editor.

json-5.ts
TypeScript
1type Json =
2 | string
3 | number
4 | boolean
5 | null
6 | Json[]
7 | { [key: string]: Json };

Snippet 6 — entries

Practice snippet 6. Predict the resulting type, then confirm with hover types in your editor.

entries-6.ts
TypeScript
1type Entries<T> = { [K in keyof T]-?: [K, T[K]] }[keyof T];
2type E = Entries<{ a: 1; b: string }>; // ["a", 1] | ["b", string]

Snippet 7 — atleast

Practice snippet 7. Predict the resulting type, then confirm with hover types in your editor.

atleast-7.ts
TypeScript
1type RequireAtLeastOne<T> = {
2 [K in keyof T]-?: Required<Pick<T, K>> & Partial<Omit<T, K>>;
3}[keyof T];
4type Patches = RequireAtLeastOne<{ a?: number; b?: string }>;

Snippet 8 — mutable-keys

Practice snippet 8. Predict the resulting type, then confirm with hover types in your editor.

mutable-keys-8.ts
TypeScript
1type MutableKeys<T> = {
2 [K in keyof T]-?: Equal<{ -readonly [P in K]: T[K] }, { [P in K]: T[K] }> extends true ? K : never;
3}[keyof T];
4type Equal<A, B> = (<T>() => T extends A ? 1 : 2) extends (<T>() => T extends B ? 1 : 2) ? true : false;

Snippet 9 — snake

Practice snippet 9. Predict the resulting type, then confirm with hover types in your editor.

snake-9.ts
TypeScript
1type Snake<S extends string> = S extends `${infer H}${infer T}`
2 ? T extends Uncapitalize<T>
3 ? `${Lowercase<H>}${Snake<T>}`
4 : `${Lowercase<H>}_${Snake<Uncapitalize<T>>}`
5 : S;

Snippet 10 — publicof

Practice snippet 10. Predict the resulting type, then confirm with hover types in your editor.

publicof-10.ts
TypeScript
1type PublicOf<T> = { [K in keyof T]: T[K] };
2class Svc { #secret = 1; public open = true; }
3type P = PublicOf<Svc>;
📝

note

These snippets are intentionally dense. Prefer understanding one fully over skimming all.
Extended Drills (zod) — Set 2

Extended reference material for deliberate practice. Work through each snippet under strict: true and note where inference surprises you.

Snippet 1 — deep-readonly

Practice snippet 1. Predict the resulting type, then confirm with hover types in your editor.

deep-readonly-1.ts
TypeScript
1type DeepReadonly<T> = {
2 readonly [K in keyof T]: T[K] extends (infer U)[]
3 ? readonly DeepReadonly<U>[]
4 : T[K] extends object
5 ? DeepReadonly<T[K]>
6 : T[K];
7};

Snippet 2 — uti

Practice snippet 2. Predict the resulting type, then confirm with hover types in your editor.

uti-2.ts
TypeScript
1type UnionToIntersection<U> =
2 (U extends unknown ? (k: U) => void : never) extends (k: infer I) => void ? I : never;
3type I = UnionToIntersection<{ a: 1 } | { b: 2 }>; // { a:1 } & { b:2 }

Snippet 3 — narrow-key

Practice snippet 3. Predict the resulting type, then confirm with hover types in your editor.

narrow-key-3.ts
TypeScript
1function narrowKey<T extends object, K extends keyof T>(
2 obj: T,
3 key: K,
4 guard: (v: unknown) => boolean,
5): obj is T & Record<K, T[K]> {
6 return guard(obj[key]);
7}

Snippet 4 — as-tuple

Practice snippet 4. Predict the resulting type, then confirm with hover types in your editor.

as-tuple-4.ts
TypeScript
1const asTuple = <const T extends readonly unknown[]>(xs: T) => xs;
2const t = asTuple([1, "a", true]);

Snippet 5 — json

Practice snippet 5. Predict the resulting type, then confirm with hover types in your editor.

json-5.ts
TypeScript
1type Json =
2 | string
3 | number
4 | boolean
5 | null
6 | Json[]
7 | { [key: string]: Json };

Snippet 6 — entries

Practice snippet 6. Predict the resulting type, then confirm with hover types in your editor.

entries-6.ts
TypeScript
1type Entries<T> = { [K in keyof T]-?: [K, T[K]] }[keyof T];
2type E = Entries<{ a: 1; b: string }>; // ["a", 1] | ["b", string]

Snippet 7 — atleast

Practice snippet 7. Predict the resulting type, then confirm with hover types in your editor.

atleast-7.ts
TypeScript
1type RequireAtLeastOne<T> = {
2 [K in keyof T]-?: Required<Pick<T, K>> & Partial<Omit<T, K>>;
3}[keyof T];
4type Patches = RequireAtLeastOne<{ a?: number; b?: string }>;

Snippet 8 — mutable-keys

Practice snippet 8. Predict the resulting type, then confirm with hover types in your editor.

mutable-keys-8.ts
TypeScript
1type MutableKeys<T> = {
2 [K in keyof T]-?: Equal<{ -readonly [P in K]: T[K] }, { [P in K]: T[K] }> extends true ? K : never;
3}[keyof T];
4type Equal<A, B> = (<T>() => T extends A ? 1 : 2) extends (<T>() => T extends B ? 1 : 2) ? true : false;

Snippet 9 — snake

Practice snippet 9. Predict the resulting type, then confirm with hover types in your editor.

snake-9.ts
TypeScript
1type Snake<S extends string> = S extends `${infer H}${infer T}`
2 ? T extends Uncapitalize<T>
3 ? `${Lowercase<H>}${Snake<T>}`
4 : `${Lowercase<H>}_${Snake<Uncapitalize<T>>}`
5 : S;

Snippet 10 — publicof

Practice snippet 10. Predict the resulting type, then confirm with hover types in your editor.

publicof-10.ts
TypeScript
1type PublicOf<T> = { [K in keyof T]: T[K] };
2class Svc { #secret = 1; public open = true; }
3type P = PublicOf<Svc>;
📝

note

These snippets are intentionally dense. Prefer understanding one fully over skimming all.
$Blueprint — Engineering Documentation·Section ID: TS-ZOD·Revision: 1.0

Community

Get help on Slack, Discord or VIP

Stuck on a guide? Join the community and ask.