3. Objects, Records, Tuples & Recursion
Real-world TypeScript applications rarely validate isolated primitives. Payloads arriving from REST endpoints, GraphQL resolvers, configuration files, and message queues are deeply nested trees of objects, arrays, tuples, key-value dictionaries, and recursive data structures. Zod v4 provides an expressive, immutable API for modeling structured shapes—featuring dedicated unknown-key constructors (z.object, z.strictObject, z.looseObject), zero-boilerplate ES getter recursion, and rich collection schemas.
Source Code
from drawlib.canvas import save, setup
from drawlib.icons import phosphor
from drawlib.smartarts import CardList, Panel, Table
from drawlib.styles import Styles
setup(width=144, height=64)
left_panel = Panel(
"Unknown Key Modes",
"Strip / Strict / Loose",
style=Styles.Neutral,
header_style=Styles.PrimaryNeutral.patch(shape_line_width=1.2),
text_style=Styles.DarkBold.patch(text_size=11.0),
description_style=Styles.Dark.patch(text_size=10.0),
icon=phosphor.sliders_horizontal,
icon_style=Styles.DarkBold,
header_variant="filled",
header_height=9.5,
)
left_panel.draw((4.0, 4.0), width=66.0, height=56.0, align="bottom_left")
l_xy, l_w, l_h = left_panel.get_content_box(
(4.0, 4.0), width=66.0, height=56.0, padding=(3.0, 2.5), align="bottom_left"
)
modes = CardList(
style=Styles.White,
text_style=Styles.DarkBold.patch(text_size=10.5),
description_style=Styles.Dark.patch(text_size=10.0),
badge_style=Styles.SecondaryNeutral,
badge_text_style=Styles.DarkBold.patch(text_size=10.0),
spacing=2.0,
)
modes.add(
"z.object(shape)",
"Strips unknown keys -> { id: 'u1' }",
badge_text="S1",
style=Styles.PrimaryFlat,
text_style=Styles.WhiteBold.patch(text_size=10.5),
description_style=Styles.White.patch(text_size=10.0),
badge_style=Styles.White,
badge_text_style=Styles.PrimaryBold.patch(text_size=10.0),
)
modes.add(
"z.strictObject(shape)",
"Rejects extra keys (unrecognized_keys)",
badge_text="S2",
)
modes.add(
"z.looseObject(shape)",
"Passes through -> { id: 'u1', extra: 42 }",
badge_text="S3",
)
modes.draw(l_xy, width=l_w, height=l_h)
right_panel = Panel(
"Shape Combinators",
"Immutable Methods",
style=Styles.Neutral,
header_style=Styles.SecondaryNeutral.patch(shape_line_width=1.2),
text_style=Styles.DarkBold.patch(text_size=11.0),
description_style=Styles.Dark.patch(text_size=10.0),
icon=phosphor.stack,
icon_style=Styles.DarkBold,
header_variant="filled",
header_height=9.5,
)
right_panel.draw((74.0, 4.0), width=66.0, height=56.0, align="bottom_left")
r_xy, r_w, r_h = right_panel.get_content_box(
(74.0, 4.0), width=66.0, height=56.0, padding=(3.0, 2.5), align="bottom_left"
)
comb_table = Table(
cell_style=Styles.White,
text_style=Styles.Dark.patch(text_size=10.0),
header_cell_style=Styles.PrimaryFlat,
header_text_style=Styles.WhiteBold.patch(text_size=10.0),
border_style=Styles.MutedThin,
)
comb_table.draw(
xy=r_xy,
width=r_w,
height=r_h,
align="bottom_left",
data=[
["Combinator", "Resulting Schema Effect"],
[".extend() / .safeExtend()", "Add or widen fields"],
[".pick(mask) / .omit(mask)", "Select or drop key subset"],
[".partial() / .required()", "Toggle optionality"],
[".catchall(schema)", "Validate all extra keys"],
[".keyof()", "Extract ZodEnum of keys"],
],
)
save()
3.1 Object Schemas & Unknown Key Handling (z.object, z.strictObject, z.looseObject)
In Zod v4, object schemas are constructed using three top-level factories that govern how properties not declared in shape are handled at runtime:
z.object(shape)(Strip Mode — Default): Validates all declared properties and silently strips any unrecognized keys from the parsed output object. This guarantees that downstream application logic only receives known, validated properties.z.strictObject(shape)(Strict Mode): Rejects inputs containing any key not declared inshape, emitting anunrecognized_keysissue listing all unexpected property names.z.looseObject(shape)(Passthrough Mode): Validates declared properties while preserving any unknown keys verbatim on the output object (typed with an index signature[x: string]: unknown).
import { z } from "zod";
// 1. Default strip mode: z.object()
const UserSchema = z.object({
id: z.uuid(),
username: z.string().min(3),
role: z.enum(["admin", "editor", "viewer"]),
});
const rawInput = {
id: "550e8400-e29b-41d4-a716-446655440000",
username: "alice",
role: "admin",
debugFlag: true, // Unknown key
};
// Strips `debugFlag` from the output
const stripped = UserSchema.parse(rawInput);
// => { id: "550e8400-...", username: "alice", role: "admin" }
// 2. Strict mode: z.strictObject()
const StrictUserSchema = z.strictObject({
id: z.uuid(),
username: z.string().min(3),
});
const strictResult = StrictUserSchema.safeParse({
id: "550e8400-e29b-41d4-a716-446655440000",
username: "alice",
debugFlag: true,
});
// strictResult.success === false
// Issue code: "unrecognized_keys", keys: ["debugFlag"]
// 3. Loose (passthrough) mode: z.looseObject()
const LooseUserSchema = z.looseObject({
id: z.uuid(),
username: z.string().min(3),
});
const looseOutput = LooseUserSchema.parse(rawInput);
// => { id: "550e8400-...", username: "alice", role: "admin", debugFlag: true }
Note: You can also convert between modes fluently on any existing
ZodObjectinstance using.strip(),.strict(), and.loose()(or its alias.passthrough()).
Validating Extra Keys with .catchall()
When an object has a known set of static properties alongside dynamic properties that must all conform to a specific schema, attach .catchall(schema):
const ServiceHeaders = z
.object({
"content-type": z.string(),
"x-request-id": z.uuid(),
})
.catchall(z.string());
type ServiceHeaders = z.infer<typeof ServiceHeaders>;
// { "content-type": string; "x-request-id": string; [x: string]: string }
3.2 Object Composition & Shape Manipulation
Every ZodObject instance is immutable. Calling a composition method returns a brand-new ZodObject with updated static TypeScript types and runtime validation definitions.
.extend() and .safeExtend()
Use .extend(shape) to add new properties or overwrite existing ones. In Zod v4, .extend() replaces the deprecated .merge() method with superior TypeScript compiler performance. If you want TypeScript to prevent accidentally overwriting an existing key with an incompatible type, use .safeExtend(shape):
const BaseEntity = z.object({
id: z.uuid(),
createdAt: z.iso.datetime(),
});
// Add new fields or override existing ones
const ArticleSchema = BaseEntity.extend({
title: z.string().min(1),
tags: z.array(z.string()),
});
// Type-safe extension: only permits adding new keys or narrowing existing keys
const AuditedArticleSchema = ArticleSchema.safeExtend({
title: z.string().min(5).max(120), // Valid narrowing of `string`
updatedBy: z.string(), // New key
});
.pick() and .omit()
Project a subset of keys using boolean masks ({ key: true }), mirroring TypeScript's built-in Pick<T, K> and Omit<T, K> utility types:
const CreateArticleInput = ArticleSchema.omit({
id: true,
createdAt: true,
});
// Inferred type: { title: string; tags: string[] }
const ArticleSummary = ArticleSchema.pick({
id: true,
title: true,
});
// Inferred type: { id: string; title: string }
.partial(), .exactPartial(), and .required()
.partial()makes all properties optional (T | undefined), or only the properties specified in a boolean mask..exactPartial()wraps properties inZodExactOptional, allowing keys to be omitted entirely from the object while rejecting explicit{ key: undefined }values (matching TypeScript'sexactOptionalPropertyTypes: true)..required()unwraps optional properties viaZodNonOptional, ensuring every field is present and notundefined.
// Make all fields optional for PATCH payloads
const PatchArticleInput = CreateArticleInput.partial();
// Make only `tags` optional while keeping `title` required
const DraftArticleInput = CreateArticleInput.partial({ tags: true });
// Require all fields again
const StrictArticleInput = PatchArticleInput.required();
.keyof() and .shape
Extract a ZodEnum of the object's top-level property keys with .keyof(), or access individual field schemas directly via .shape:
const ArticleKeyEnum = ArticleSchema.keyof();
// ZodEnum<["id", "createdAt", "title", "tags"]>
const TitleSchema = ArticleSchema.shape.title;
// ZodString
3.3 Native Recursive Schemas via ES Getters
In Zod v3, defining recursive data structures (such as category trees, UI component hierarchies, or AST nodes) required manual z.lazy(() => ...) wrappers and explicit z.ZodType<Category> type annotations that duplicated the entire schema definition.
Zod v4 eliminates this boilerplate with native ES getter recursion. By defining a recursive property using a standard JavaScript get prop() getter inside z.object(), z.strictObject(), or z.looseObject(), TypeScript infers the cyclic type directly and Zod lazily evaluates and memoizes the property reference:
Source Code
from drawlib.canvas import save, setup
from drawlib.icons import phosphor
from drawlib.smartarts import CardList, Panel
from drawlib.styles import Styles
setup(width=144, height=64)
left_panel = Panel(
"v4 Getter Recursion",
"No z.lazy() Needed",
style=Styles.PrimaryNeutral,
header_style=Styles.PrimaryFlat,
text_style=Styles.WhiteBold.patch(text_size=11.0),
description_style=Styles.White.patch(text_size=10.0),
icon=phosphor.arrows_clockwise,
icon_style=Styles.White,
header_variant="filled",
header_height=9.5,
)
left_panel.draw((4.0, 4.0), width=66.0, height=56.0, align="bottom_left")
l_xy, l_w, l_h = left_panel.get_content_box(
(4.0, 4.0), width=66.0, height=56.0, padding=(3.0, 2.5), align="bottom_left"
)
rec_cards = CardList(
style=Styles.White,
text_style=Styles.DarkBold.patch(text_size=10.5),
description_style=Styles.Dark.patch(text_size=10.0),
badge_style=Styles.PrimaryFlat,
badge_text_style=Styles.WhiteBold.patch(text_size=10.0),
spacing=2.2,
show_arrows=True,
arrow_style=Styles.PrimaryBold,
)
rec_cards.add(
"1. Object Shape Initialization",
"Static keys evaluated immediately",
badge_text="S1",
)
rec_cards.add(
"2. ES Getter Property",
"get subcategories() { return z.array(Category) }",
badge_text="S2",
)
rec_cards.add(
"3. Memoized Cyclic Resolution",
"Full z.infer<T> & JSON Schema $ref support",
badge_text="S3",
)
rec_cards.draw(l_xy, width=l_w, height=l_h)
right_panel = Panel(
"Collection Schemas",
"Arrays, Records & Sets",
style=Styles.Neutral,
header_style=Styles.SecondaryNeutral.patch(shape_line_width=1.2),
text_style=Styles.DarkBold.patch(text_size=11.0),
description_style=Styles.Dark.patch(text_size=10.0),
icon=phosphor.list_bullets,
icon_style=Styles.DarkBold,
header_variant="filled",
header_height=9.5,
)
right_panel.draw((74.0, 4.0), width=66.0, height=56.0, align="bottom_left")
r_xy, r_w, r_h = right_panel.get_content_box(
(74.0, 4.0), width=66.0, height=56.0, padding=(3.0, 2.5), align="bottom_left"
)
col_cards = CardList(
style=Styles.White,
text_style=Styles.DarkBold.patch(text_size=10.5),
description_style=Styles.Dark.patch(text_size=10.0),
badge_style=Styles.SecondaryNeutral,
badge_text_style=Styles.DarkBold.patch(text_size=10.0),
spacing=2.2,
)
col_cards.add(
"z.array(T) & z.tuple([...], Rest)",
"Homogeneous lists & fixed/variadic tuples",
badge_text="SEQ",
)
col_cards.add(
"z.record(K, V) & z.partialRecord(K, V)",
"Dynamic dictionaries & enum key maps",
badge_text="REC",
)
col_cards.add(
"z.map(K, V) & z.set(T)",
"Native ES6 Map/Set with .min()/.max()/.size()",
badge_text="ES6",
)
col_cards.draw(r_xy, width=r_w, height=r_h)
save()
Self-Referential Tree Schema
import { z } from "zod";
const Category = z.object({
id: z.string(),
name: z.string(),
get subcategories() {
return z.array(Category);
},
});
type Category = z.infer<typeof Category>;
// {
// id: string;
// name: string;
// subcategories: Category[];
// }
const catalogTree = Category.parse({
id: "root",
name: "Electronics",
subcategories: [
{
id: "laptops",
name: "Laptops",
subcategories: [],
},
],
});
Mutually Recursive Schemas
Getter recursion works seamlessly across mutually recursive types—such as User and Post schemas that reference one another—and supports full composition (.extend(), .pick(), .omit(), .partial()) as well as automatic $ref cycle emission in z.toJSONSchema():
const User = z.object({
id: z.uuid(),
handle: z.string(),
get posts() {
return z.array(Post);
},
});
const Post = z.object({
id: z.uuid(),
title: z.string(),
get author() {
return User;
},
get comments() {
return z.array(Post);
},
});
type User = z.infer<typeof User>;
type Post = z.infer<typeof Post>;
When is
z.lazy()still needed? Only when defining a top-level recursive union that is not inside an object shape (for example, a custom recursive JSON value unionconst Json: z.ZodType<JsonValue> = z.lazy(() => z.union([...]))). Note that Zod v4 providesz.json()out of the box for arbitrary JSON values!
3.4 Arrays & Variadic Tuples (z.array, z.tuple)
Homogeneous Arrays (z.array)
Construct array schemas with z.array(elementSchema) or the fluent .array() method on any schema. Restrict array cardinality with .min(n), .max(n), .length(n), or .nonempty():
const TagList = z.array(z.string().min(1)).min(1).max(10);
// Equivalent shorthand: z.string().min(1).array().min(1).max(10)
// Access the inner element schema via `.element`:
const SingleTag = TagList.element; // ZodString
Note: In Zod v4,
.nonempty()on an array acts identically to.min(1)and preserves the inferred type asT[]rather than narrowing to[T, ...T[]]. If you require a fixed leading element type[T, ...T[]], use a variadicz.tuple([T], T).
Fixed & Variadic Tuples (z.tuple)
Tuples validate fixed-length arrays where each index has its own schema. In Zod v4, z.tuple(items, restSchema) accepts an optional second argument (or .rest(restSchema)) to model variadic TypeScript tuples [A, B, ...Rest[]]:
// Fixed-length tuple: [string, number, boolean]
const Point2DWithFlag = z.tuple([z.string(), z.number(), z.boolean()]);
// Variadic tuple: [string, ...number[]]
const MetricSeries = z.tuple([z.string()], z.number());
type MetricSeries = z.infer<typeof MetricSeries>;
// [string, ...number[]]
MetricSeries.parse(["cpu_load", 0.42, 0.51, 0.38]); // Valid!
// Make all fixed tuple positions optional:
const PartialPoint = Point2DWithFlag.partial();
// [(string | undefined)?, (number | undefined)?, (boolean | undefined)?]
3.5 Records, Maps & Sets (z.record, z.map, z.set)
Key-Value Records (z.record, z.partialRecord, z.looseRecord)
In Zod v4, z.record(keySchema, valueSchema) takes two explicit arguments: the key schema and the value schema.
// Standard string-keyed dictionary: Record<string, number>
const FeatureScores = z.record(z.string(), z.number().min(0).max(1));
// Pattern-constrained keys (e.g. environment variable names)
const EnvVars = z.record(z.string().regex(/^[A-Z_][A-Z0-9_]*$/), z.string());
When the key schema is a finite z.enum(...) or union of literals, Zod v4 enforces exhaustive key validation by default (Record<"dev" | "staging" | "prod", string>). If keys are optional or unknown keys should be ignored, use the v4 companion constructors:
| Constructor | Inferred Type for Enum Key K |
Runtime Behavior on Keys |
|---|---|---|
z.record(K, V) |
Record<K, V> |
Requires every key in finite K to be present; rejects unknown keys. |
z.partialRecord(K, V) |
Partial<Record<K, V>> |
Allows omitting keys in K; still rejects keys outside K. |
z.looseRecord(K, V) |
Record<string, unknown> & ... |
Validates keys matching K against V and passes non-matching keys through unchanged. |
const Environment = z.enum(["dev", "staging", "prod"]);
// 1. Exhaustive record: requires all 3 environment keys
const AllEndpoints = z.record(Environment, z.url());
// 2. Partial record: allows specifying a subset of environments
const OverrideEndpoints = z.partialRecord(Environment, z.url());
OverrideEndpoints.parse({ dev: "https://dev.api.example.com" }); // Valid!
Native ES6 Map and Set (z.map, z.set)
When validating runtime Map and Set instances (such as in-memory caches or deduplicated ID collections), use z.map(keySchema, valueSchema) and z.set(valueSchema). Both support .min(n), .max(n), .size(n), and .nonempty():
const UserPermissionsMap = z
.map(z.uuid(), z.set(z.enum(["read", "write", "delete"])).nonempty())
.min(1)
.max(1000);
type UserPermissionsMap = z.infer<typeof UserPermissionsMap>;
// Map<string, Set<"read" | "write" | "delete">>
const permissions = new Map([
["550e8400-e29b-41d4-a716-446655440000", new Set(["read", "write"] as const)],
]);
UserPermissionsMap.parse(permissions);
Next, proceed to 4. Unions, Discriminated Unions & Intersections to learn how Zod v4 dispatches polymorphic schemas in $O(1)$ time and handles optional, nullable, and default value pipelines.