Zod 03 🛡️ Defining a Schema — z.object(), Primitives, and Schema Composition
A Zod schema is a value that carries two things at once: a runtime validator and a TypeScript type. Defining a schema means composing primitives—z.string(), z.number(), z.boolean()—into objects, arrays, and unions that mirror the data your application actually receives. Every schema is immutable; methods like .optional() and .extend() return new schemas rather than mutating the original . This immutability is what makes composition safe: a base schema can be shared across the codebase without any call site accidentally changing its shape.
This chapter covers the primitives that form the vocabulary of every schema, the z.object() constructor that assembles them into structures, and the composition patterns—nesting, extending, unions, and discriminated unions—that handle real-world data without duplicating field definitions. The sections that follow walk through primitives and their type inference, object construction and extension, and the union patterns that make complex data tractable.
Key point: A Zod schema is both the runtime validator and the static type; composing schemas composes both, so a single z.infer call produces a type that always matches the validation logic.
Why schema composition matters
The duplication problem. A user has an address. An order has a shipping address and a billing address. A company has a headquarters address. Without composition, each of these schemas re-declares street, city, and zip, and a change to the zip format requires edits in three places. Zod’s composition solves this by treating schemas as first-class values: define AddressSchema once, reference it wherever an address appears, and the inferred types nest automatically .
The optional/nullable confusion problem. TypeScript distinguishes undefined from null, and so does Zod. z.string().optional() infers as string | undefined; z.string().nullable() infers as string | null; z.string().nullish() infers as string | null | undefined. Choosing the wrong wrapper produces a schema that rejects valid data or a type that permits invalid data. Composition makes the choice explicit at every field .
The union discrimination problem. A naive union like z.union([CustomerSchema, AdminSchema]) tries each branch in sequence, which means an invalid input runs every branch’s validation before failing. When the branches share a literal discriminator field (type: "customer" versus type: "admin"), z.discriminatedUnion() reads the discriminator and validates only the matching branch, producing faster validation and clearer errors when the discriminator itself is wrong .
a. Primitives and their inferred types
Zod’s primitives map directly to TypeScript’s primitive types. The schema method name is the type name, and the inferred type is what you would expect. Chained constraints—.min(), .max(), .email(), .int()—refine the runtime check without changing the inferred type.
import { z } from "zod";
// Primitives and their inferred types
const StringSchema = z.string(); // string
const NumberSchema = z.number(); // number
const BooleanSchema = z.boolean(); // boolean
const DateSchema = z.date(); // Date
const BigIntSchema = z.bigint(); // bigint
// Constraints refine runtime behavior, not the inferred type
const EmailSchema = z.string().email(); // still string
const AgeSchema = z.number().int().positive(); // still number
The wrapper methods change the inferred type. .optional() adds undefined; .nullable() adds null; .default() removes undefined from the output type because the default fills it in. z.enum() produces a union of string literals, which is more precise than z.string() and should be preferred over z.union([z.literal("a"), z.literal("b")]) for small sets .
// Wrappers change the type
const BioSchema = z.string().optional(); // string | undefined
const AvatarSchema = z.string().nullable(); // string | null
const WebsiteSchema = z.string().nullish(); // string | null | undefined
// Enum produces a literal union
const RoleSchema = z.enum(["admin", "editor", "viewer"]);
// type Role = "admin" | "editor" | "viewer"
b. z.object() and schema extension
z.object() takes a shape—an object whose values are schemas—and returns a schema whose inferred type is the object with each field’s type resolved. This is the primary constructor for structured data. The inferred type preserves optionality and nullability exactly as declared.
const UserSchema = z.object({
id: z.string().uuid(),
name: z.string().min(1),
email: z.string().email(),
age: z.number().int().positive().optional(),
role: z.enum(["admin", "editor", "viewer"]),
});
type User = z.infer<typeof UserSchema>;
// {
// id: string;
// name: string;
// email: string;
// age?: number;
// role: "admin" | "editor" | "viewer";
// }
Composition happens by nesting schema references. The AddressSchema is defined once and used as a field value in OrderSchema; there is no re-declaration of its fields . .extend() adds fields to an existing object schema without redefining the base, which is useful when multiple schemas share a common set of fields but diverge on a few.
const AddressSchema = z.object({
street: z.string(),
city: z.string(),
zip: z.string().regex(/^\d{5}$/),
});
const OrderSchema = z.object({
id: z.string().uuid(),
shippingAddress: AddressSchema, // nested reference
billingAddress: AddressSchema.optional(),
});
// Extend a base schema
const BaseUserSchema = z.object({
id: z.number().positive(),
createdAt: z.date(),
});
const CustomerSchema = BaseUserSchema.extend({
customerType: z.literal("customer"),
orders: z.array(z.object({ orderId: z.string() })),
});
The .extend() method returns a new schema; the base is unchanged. This is the immutability guarantee in practice: BaseUserSchema still validates only id and createdAt after CustomerSchema is derived from it .
c. Unions and discriminated unions
A union accepts data that matches any of its branches. z.union([A, B]) tries each branch and succeeds if any matches. This is correct for data where the branches are genuinely interchangeable at the type level, such as a field that accepts either a string or a number .
const StringOrNumberSchema = z.union([z.string(), z.number()]);
StringOrNumberSchema.parse("hello"); // valid
StringOrNumberSchema.parse(123); // valid
StringOrNumberSchema.parse(true); // ZodError: expected string | number
A discriminated union is the correct choice when every branch shares a literal field that identifies which branch applies. z.discriminatedUnion("type", [A, B]) reads the type field first, then validates only the matching branch. This is faster and produces a clearer error when the discriminator is missing or unknown, because the error names the discriminator rather than listing every failed branch .
const CustomerSchema = z.object({
type: z.literal("customer"),
orders: z.array(z.string()),
});
const AdminSchema = z.object({
type: z.literal("admin"),
permissions: z.array(z.string()),
});
// Discriminated union: validates only the matching branch
const UserSchema = z.discriminatedUnion("type", [CustomerSchema, AdminSchema]);
UserSchema.parse({ type: "customer", orders: ["order1"] }); // valid
UserSchema.parse({ type: "admin", permissions: ["read"] }); // valid
UserSchema.parse({ type: "unknown" }); // error on discriminator
Complete Example Session
// ============================================
// PART 1: PRIMITIVES AND WRAPPERS
// ============================================
import { z } from "zod";
const NameSchema = z.string().min(1, "Name is required");
const AgeSchema = z.number().int().positive("Age must be positive");
const BioSchema = z.string().optional(); // string | undefined
const AvatarSchema = z.string().url().nullable(); // string | null
// ============================================
// PART 2: THE ADDRESS SCHEMA (REUSABLE)
// ============================================
const AddressSchema = z.object({
street: z.string(),
city: z.string(),
zip: z.string().regex(/^\d{5}$/, "ZIP must be 5 digits"),
});
// ============================================
// PART 3: THE USER SCHEMA WITH NESTING
// ============================================
const UserSchema = z.object({
id: z.string().uuid(),
name: NameSchema,
email: z.string().email(),
age: AgeSchema.optional(),
homeAddress: AddressSchema, // nested reference
workAddress: AddressSchema.optional(), // same schema, optional
bio: BioSchema,
avatar: AvatarSchema,
});
// ============================================
// PART 4: INFERRED TYPE
// ============================================
type User = z.infer<typeof UserSchema>;
// {
// id: string;
// name: string;
// email: string;
// age?: number;
// homeAddress: { street: string; city: string; zip: string };
// workAddress?: { street: string; city: string; zip: string };
// bio?: string;
// avatar: string | null;
// }
// ============================================
// PART 5: EXTENSION FOR A CUSTOMER
// ============================================
const CustomerSchema = UserSchema.extend({
type: z.literal("customer"),
loyaltyPoints: z.number().int().nonnegative(),
});
// ============================================
// PART 6: DISCRIMINATED UNION
// ============================================
const AdminSchema = UserSchema.extend({
type: z.literal("admin"),
permissions: z.array(z.string()),
});
const AccountSchema = z.discriminatedUnion("type", [
CustomerSchema,
AdminSchema,
]);
// ============================================
// PART 7: PARSING
// ============================================
const result = AccountSchema.safeParse({
type: "customer",
id: "550e8400-e29b-41d4-a716-446655440000",
name: "Ana",
email: "ana@example.com",
homeAddress: { street: "Main St", city: "Springfield", zip: "12345" },
avatar: null,
loyaltyPoints: 100,
});
if (result.success) {
console.log(result.data.loyaltyPoints); // typed as number
}
The parts covered primitives with wrappers, a reusable nested schema, the composed user schema, the inferred type, extension, discriminated union construction, and parsing with branch-specific type narrowing.
Quick Reference
Primitives
| Schema | Inferred Type | Notes |
|---|---|---|
z.string() | string | Add .email(), .uuid(), .min() |
z.number() | number | Add .int(), .positive() |
z.boolean() | boolean | — |
z.date() | Date | — |
z.enum([...]) | Literal union | Prefer over literal union |
z.literal(v) | Exact value | z.literal("admin") |
Wrappers
| Method | Inferred Type |
|---|---|
.optional() | T | undefined |
.nullable() | T | null |
.nullish() | T | null | undefined |
.default(v) | T (output) |
Composition Methods
| Method | Purpose | Returns |
|---|---|---|
z.object({...}) | Define object shape | New schema |
.extend({...}) | Add fields to object | New schema |
z.union([...]) | Any branch matches | Union schema |
z.discriminatedUnion(key, [...]) | Discriminator selects branch | Union schema |
z.array(schema) | Array of schema | Array schema |
Best Practices
✅ Do This:
// Define reusable schemas once and reference them
const AddressSchema = z.object({ street: z.string(), city: z.string() }); // ✅
z.object({ home: AddressSchema, work: AddressSchema.optional() }); // ✅
// Use enum for fixed string sets
z.enum(["admin", "editor", "viewer"]) // ✅
// Use discriminatedUnion when a literal field identifies the branch
z.discriminatedUnion("type", [CustomerSchema, AdminSchema]) // ✅
// Choose the correct wrapper
z.string().optional() // undefined, not present // ✅
z.string().nullable() // null, present but empty // ✅
// Extend a base schema instead of re-declaring fields
BaseSchema.extend({ extra: z.string() }) // ✅
❌ Don’t Do This:
// Re-declare fields instead of composing
z.object({ shippingStreet: z.string(), billingStreet: z.string() }) // ❌
// Use union of literals instead of enum
z.union([z.literal("a"), z.literal("b")]) // ❌
// Use naive union when a discriminator exists
z.union([CustomerSchema, AdminSchema]) // ❌ (slower, unclear errors)
// Mix optional and nullable without intent
z.string().nullish() // only if both are truly possible // ❌
// Mutate a schema (schemas are immutable)
Schema.shape.name = z.number() // ❌ (no effect)
Common Pitfalls
| Pitfall | Why It Happens | Fix |
|---|---|---|
| Fields duplicated across schemas | No shared schema reference | Define once, reference by variable |
| Union errors list every branch | Naive z.union | z.discriminatedUnion |
| Optional field inferred as required | Wrong wrapper | .optional(), not .default() |
| Extended schema mutates base | Misunderstanding immutability | .extend() returns new schema |
| Enum accepts arbitrary strings | Used z.string() | z.enum([...]) |
Real-World Examples
1. Reusable address schema
const AddressSchema = z.object({ street: z.string(), city: z.string(), zip: z.string() });
z.object({ home: AddressSchema, work: AddressSchema.optional() });
2. Enum for roles
z.enum(["admin", "editor", "viewer"]);
3. Discriminated union for events
z.discriminatedUnion("type", [ClickEventSchema, KeyEventSchema]);
4. Extension for variants
BaseUserSchema.extend({ type: z.literal("customer"), points: z.number() });
5. Optional versus nullable
bio: z.string().optional(), // may be absent
avatar: z.string().nullable(), // present but null
6. Array of objects
z.array(z.object({ sku: z.string(), qty: z.number() }));
7. Nested reference
z.object({ user: UserSchema, address: AddressSchema });
8. Literal discriminator
z.literal("customer");
9. Union of primitives
z.union([z.string(), z.number()]);
10. Export schema and type together
export const UserSchema = z.object({ name: z.string() });
export type User = z.infer<typeof UserSchema>;
Visual
Schema Composition Flow
┌─────────────────────────────────────────────────────────┐
│ FROM PRIMITIVES TO COMPOSED SCHEMA │
│ │
│ z.string() ──┐ │
│ z.number() ──┼──► z.object({ name, age }) │
│ z.boolean() ─┘ │ │
│ │ │
│ ▼ │
│ z.object({ street, city, zip }) │
│ │ │
│ ▼ │
│ UserSchema.extend({ homeAddress: AddressSchema }) │
│ │ │
│ ▼ │
│ z.infer<typeof UserSchema> │
│ { name: string; age: number; homeAddress: { ... } } │
│ │
│ One schema, one type, no duplication │
└─────────────────────────────────────────────────────────┘
Wrapper Types
┌─────────────────────────────────────────────────────────┐
│ OPTIONAL VS NULLABLE VS NULLISH │
│ │
│ z.string().optional() │
│ ┌──────────────────────────────────────────┐ │
│ │ Value may be absent from the object │ │
│ │ { bio?: string } │ │
│ └──────────────────────────────────────────┘ │
│ │
│ z.string().nullable() │
│ ┌──────────────────────────────────────────┐ │
│ │ Key must exist, value may be null │ │
│ │ { avatar: string | null } │ │
│ └──────────────────────────────────────────┘ │
│ │
│ z.string().nullish() │
│ ┌──────────────────────────────────────────┐ │
│ │ Both absent and null are valid │ │
│ │ { website?: string | null } │ │
│ └──────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────┘
Discriminated Union Branch Selection
┌─────────────────────────────────────────────────────────┐
│ z.discriminatedUnion("type", [A, B]) │
│ │
│ input: { type: "customer", orders: [...] } │
│ │ │
│ ▼ │
│ read discriminator: type === "customer" │
│ │ │
│ ▼ │
│ validate against CustomerSchema only │
│ │ │
│ ▼ │
│ AdminSchema never executed │
│ │
│ Benefit: faster, clearer errors on discriminator │
│ failure, no cross-branch noise │
└─────────────────────────────────────────────────────────┘
Summary
| Item | Value |
|---|---|
| Object constructor | z.object({ field: schema }) |
| Primitive examples | z.string(), z.number(), z.boolean() |
| Optional wrapper | .optional() → T | undefined |
| Nullable wrapper | .nullable() → T | null |
| Reuse | Reference schema by variable |
| Extension | .extend({...}) returns new schema |
| Union | z.union([A, B]) |
| Discriminated union | z.discriminatedUnion("type", [...]) |
| Immutability | All methods return new schemas |
Key takeaways:
- Schemas are values, not declarations.
z.object()returns a runtime validator and a type source; composition passes both through every reference . - Nesting is the primary reuse pattern. Define
AddressSchemaonce and reference it wherever an address appears; the inferred type nests automatically without re-declaration. - Wrappers encode presence semantics.
.optional()means the key may be absent;.nullable()means the value may be null;.nullish()permits both. Choosing wrong produces a schema that rejects valid data. z.enum()beats literal unions. For a fixed set of strings, the enum produces the same type with less verbosity and better autocomplete .- Discriminated unions are for tagged data. When a literal field identifies the branch,
z.discriminatedUnion()validates only the matching schema, producing faster validation and errors that name the discriminator rather than every failed branch . .extend()preserves immutability. The base schema is unchanged; the extended schema is a new value with the composed type .
Remember: Defining a Zod schema is not writing a validator with types attached; it is constructing a type through composition. Every z.object() call is a type constructor, every .extend() derives a new type, and every nested schema reference embeds its type in the parent. The primitives are the vocabulary, the wrappers encode presence, and the composition methods build structures that match the data. When the schema is right, the type is right, and there is no second declaration to drift.
Stop using slow, ad-bloated tool sites! 🤮
🔎 Search “KandZ Tools” on Google to use many professional utilities for free.
KandZ.me is the ultimate minimalist hub for:
✅ Finance (Mortgage, Interest, Inflation)
✅ Tech (Base64, JSON, Dev Suite, IP)
✅ Health (BMI, BMR, TDEE)
✅ Productivity (Timer, Workspace, QR)
⚡️ Fast & Private
🔒 No data leaves your device
💎 100% Free
🔗 Use it now: https://tools.kandz.me
🔖 Bookmark it—you’ll need it later!