$ cat docs/zod-+-typescript.md
updated Today · 20 min read · published
Zod + TypeScript 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
Boundary Risk without validation With Zod HTTP JSON body any-shaped objects parse → typed value or error process.env string | undefined everywhere coerced typed config localStorage stale/corrupt data safeParse + fallback Form values stringly typed validated domain object
ℹ info
Compile-time types protect your code from yourself. Runtime schemas protect your code from the world.
Schemas & z.infer
Copy 1 import { z } from "zod"; 2 3 const 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 10 type User = z.infer<typeof UserSchema>; 11 // { id: string; email: string; age: number; role: "user" | "admin" } 12 13 const parsed = UserSchema.parse(JSON.parse(raw)); // throws ZodError 14 const result = UserSchema.safeParse(data); 15 if (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
API On failure Use when parse throws ZodError Trusted internal after boundary; scripts safeParse returns { success } HTTP handlers, UI forms parseAsync async refinements DB uniqueness checks
Copy 1 export function parseUser(input: unknown): User { 2 return UserSchema.parse(input); 3 } 4 5 export 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 wrap TypeScript
Copy 1 const Tag = z.object({ name: z.string(), color: z.string() }); 2 const 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 8 const 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 ]); 12 type Event = z.infer<typeof Event>;
Branded Types with Zod
Copy 1 const UserId = z.string().uuid().brand<"UserId">(); 2 type UserId = z.infer<typeof UserId>; 3 4 function loadUser(id: UserId) {} 5 const id = UserId.parse(req.params.id); 6 loadUser(id); 7 // loadUser("not-branded"); // Error
API Boundary Pattern
Copy 1 import { z } from "zod"; 2 3 const CreatePostInput = z.object({ 4 title: z.string().min(1).max(120), 5 body: z.string().min(1), 6 }); 7 export type CreatePostInput = z.infer<typeof CreatePostInput>; 8 9 export 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
Copy 1 const 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 7 export const env = Env.parse(process.env);
Schemas vs Hand-Written Types
Approach Pros Cons Zod first + z.infer Single source, runtime safe Library dependency Types first + Zod mirrors Types feel primary Drift risk Types only Zero runtime cost Unsafe at boundaries
✓ best practice
Zod-first at every trust boundary. Pure internal domain code can stay types-only.
Error Handling UX
Copy 1 const r = UserSchema.safeParse(data); 2 if (!r.success) { 3 const fieldErrors = r.error.flatten().fieldErrors; 4 // { email?: string[]; age?: string[] } 5 return fieldErrors; 6 }
Testing Schemas
Copy 1 import { expect, test } from "vitest"; 2 3 test("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 13 test("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 wrap TypeScript
Copy 1 const 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 }); 6 type PageQuery = z.infer<typeof PageQuery>; 7 8 export function parseQuery(url: URL) { 9 return PageQuery.parse(Object.fromEntries(url.searchParams)); 10 }
Recursive schemas
recursive.ts wrap TypeScript
Copy 1 type Category = { 2 name: string; 3 children: Category[]; 4 }; 5 6 const CategorySchema: z.ZodType<Category> = z.lazy(() => 7 z.object({ 8 name: z.string(), 9 children: z.array(CategorySchema), 10 }), 11 );
Shared schema modules Copy 1 // schemas/user.ts 2 export const UserSchema = z.object({ 3 id: z.string().uuid(), 4 email: z.string().email(), 5 }); 6 export type User = z.infer<typeof UserSchema>; 7 8 // schemas/post.ts 9 export const PostSchema = z.object({ 10 id: z.string().uuid(), 11 author: UserSchema, 12 title: z.string(), 13 }); 14 export type Post = z.infer<typeof PostSchema>;
OpenAPI-ish description
describe.ts wrap TypeScript
Copy 1 const 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:
Copy 1 interface LegacyUser { 2 id: string; 3 email: string; 4 } 5 6 const 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.
Effects Pipeline
untitled.typescript wrap TypeScript
Copy 1 const TrimmedEmail = z 2 .string() 3 .transform((s) => s.trim()) 4 .pipe(z.string().email()); 5 6 const 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 .
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 wrap TypeScript
Copy 1 type Id = string & { readonly __brand: unique symbol }; 2 function asId(s: string): Id { return s as Id; } 3 4 type Entity = { id: Id; createdAt: Date }; 5 function touch(e: Entity): Entity { 6 return { ...e, createdAt: new Date() }; 7 }
Example B — conditional distribute
untitled.typescript wrap TypeScript
Copy 1 type IsString<T> = T extends string ? true : false; 2 type A = IsString<"hi" | 1>; // true | false (distributed) 3 4 type IsStringBox<T> = [T] extends [string] ? true : false; 5 type B = IsStringBox<"hi" | 1>; // false
Example C — key remapping filter
untitled.typescript wrap TypeScript
Copy 1 type OnlyStrings<T> = { 2 [K in keyof T as T[K] extends string ? K : never]: T[K]; 3 }; 4 type User = { id: string; age: number; name: string }; 5 type Names = OnlyStrings<User>; // { id: string; name: string }
Example D — template route params
untitled.typescript wrap TypeScript
Copy 1 type 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; 7 type 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 1 What is the difference between annotation, assertion, and satisfies? 2 When does a conditional type distribute over a union? 3 How do you prove exhaustiveness for a discriminated union? 4 Why is unknown safer than any at JSON boundaries? 5 What does noUncheckedIndexedAccess change about obj[key]? 6 How do you preserve literal types through a generic helper? 7 When should you prefer interface over type (and vice versa)? 8 How 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.
Copy 1 curl -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 wrap TypeScript
Copy 1 type 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.
Copy 1 type UnionToIntersection<U> = 2 (U extends unknown ? (k: U) => void : never) extends (k: infer I) => void ? I : never; 3 type 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 wrap TypeScript
Copy 1 function 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 wrap TypeScript
Copy 1 const asTuple = <const T extends readonly unknown[]>(xs: T) => xs; 2 const t = asTuple([1, "a", true]);
Snippet 5 — json Practice snippet 5. Predict the resulting type, then confirm with hover types in your editor.
Copy 1 type 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 wrap TypeScript
Copy 1 type Entries<T> = { [K in keyof T]-?: [K, T[K]] }[keyof T]; 2 type 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 wrap TypeScript
Copy 1 type RequireAtLeastOne<T> = { 2 [K in keyof T]-?: Required<Pick<T, K>> & Partial<Omit<T, K>>; 3 }[keyof T]; 4 type 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 wrap TypeScript
Copy 1 type 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]; 4 type 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.
Copy 1 type 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 wrap TypeScript
Copy 1 type PublicOf<T> = { [K in keyof T]: T[K] }; 2 class Svc { #secret = 1; public open = true; } 3 type 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 wrap TypeScript
Copy 1 type 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.
Copy 1 type UnionToIntersection<U> = 2 (U extends unknown ? (k: U) => void : never) extends (k: infer I) => void ? I : never; 3 type 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 wrap TypeScript
Copy 1 function 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 wrap TypeScript
Copy 1 const asTuple = <const T extends readonly unknown[]>(xs: T) => xs; 2 const t = asTuple([1, "a", true]);
Snippet 5 — json Practice snippet 5. Predict the resulting type, then confirm with hover types in your editor.
Copy 1 type 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 wrap TypeScript
Copy 1 type Entries<T> = { [K in keyof T]-?: [K, T[K]] }[keyof T]; 2 type 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 wrap TypeScript
Copy 1 type RequireAtLeastOne<T> = { 2 [K in keyof T]-?: Required<Pick<T, K>> & Partial<Omit<T, K>>; 3 }[keyof T]; 4 type 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 wrap TypeScript
Copy 1 type 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]; 4 type 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.
Copy 1 type 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 wrap TypeScript
Copy 1 type PublicOf<T> = { [K in keyof T]: T[K] }; 2 class Svc { #secret = 1; public open = true; } 3 type 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