Zod 09 🛡️ Object Schemas — Optional fields, defaults, and .strictObject()
An object schema is the primary constructor for structured data in Zod. It takes a shape—an object whose values are schemas—and returns a schema whose inferred type mirrors that shape. The behavior on unknown keys, however, is not uniform: Zod objects strip unknown keys by default, reject them when constructed with z.strictObject(), or preserve them with z.looseObject(). In Zod v4, the .strict() and .passthrough() methods are deprecated in favor of these top-level constructors, and the default remains strip .
This chapter covers the three object modes, the field modifiers that control optionality and defaults, and the v4 migration that moved strict behavior from a chained method to a constructor. It explains why z.object() is the safe default, when z.strictObject() prevents silent data loss, and how .default() and .optional() interact with the inferred type. The sections that follow walk through optional and default fields, the three object modes, and the strict-object migration.
Key point: z.object() strips unknown keys, z.strictObject() rejects them, and z.looseObject() preserves them; optional fields accept absence, defaults supply a value when absent, and the two are not interchangeable.
Why object schema modes exist
The silent-strip problem. Zod’s default behavior on an object schema is to remove any key that is not declared in the shape. A payload with a typo—emial instead of email—parses successfully, the misspelled key disappears, and the application runs with an absent email that the schema never reported . The strip default is correct for public API bodies where clients may send extra metadata, but it is dangerous for internal configuration where an unexpected key is almost certainly a mistake.
The strictness problem. z.strictObject() changes the behavior from silent removal to an explicit error. An unknown key produces a validation failure naming the unrecognized key, which surfaces the typo at the boundary instead of letting it propagate as a missing value . The migration from v3’s .strict() to v4’s z.strictObject() is mechanical, but the decision of which objects should be strict is not: config files, internal payloads, and anything where both ends are controlled are candidates; public API request bodies are usually not .
The optional-versus-default problem. .optional() makes a field accept undefined and infers T | undefined. .default(v) supplies v when the input is undefined and infers T on the output, because the parser fills the value in . A field that should fall back to a value when absent uses .default(); a field that should genuinely be allowed to be missing uses .optional(). Using one where the other is intended produces either a missing value at runtime or an unnecessary required field in the type.
a. Optional fields and defaults
.optional() marks a field as permitted to be absent. The inferred type includes undefined, and parsing succeeds whether the key is present with a value or absent entirely.
import { z } from "zod";
const ProfileSchema = z.object({
name: z.string(),
bio: z.string().optional(), // string | undefined
avatar: z.string().nullable(), // string | null (present but null)
});
type Profile = z.infer<typeof ProfileSchema>;
// { name: string; bio?: string; avatar: string | null }
.default() supplies a value when the input is undefined. The output type does not include undefined for that field, because the parser guarantees a value. The input type, however, does include it, which is why z.input and z.infer diverge on defaulted fields.
const SettingsSchema = z.object({
theme: z.enum(["light", "dark"]).default("light"),
pageSize: z.number().default(20),
});
type SettingsInput = z.input<typeof SettingsSchema>;
// { theme?: "light" | "dark"; pageSize?: number }
type Settings = z.infer<typeof SettingsSchema>;
// { theme: "light" | "dark"; pageSize: number }
// Parsing an empty object succeeds and fills both defaults
const parsed = SettingsSchema.parse({});
// { theme: "light", pageSize: 20 }
The v4 changelog notes a behavioral change in how defaults are applied within optional fields; the .prefault() method exists for cases where a default should be applied before validation rather than after . For most application schemas, .default() is the correct method, and the input/output distinction is handled by typing raw data with z.input and parsed data with z.infer.
b. The three object modes
Zod v4 defines three object constructors, each with a different policy on unknown keys. The default z.object() strips unknown keys, z.strictObject() rejects them, and z.looseObject() preserves them .
// Default: unknown keys are stripped from the output
const Stripped = z.object({ name: z.string() });
Stripped.parse({ name: "Ana", extra: 1 }); // { name: "Ana" }
// Strict: unknown keys cause a validation error
const Strict = z.strictObject({ name: z.string() });
Strict.parse({ name: "Ana", extra: 1 }); // ZodError: unrecognized key
// Loose: unknown keys are kept in the output
const Loose = z.looseObject({ name: z.string() });
Loose.parse({ name: "Ana", extra: 1 }); // { name: "Ana", extra: 1 }
The strip default is the right choice for public API request bodies, where a client sending an extra field should not fail the request. Strict is the right choice for internal payloads and configuration files, where an unexpected key is likely a typo. Loose is for envelopes that are passed through without the application owning their contents .
The .strict() and .passthrough() methods from v3 still exist in v4 but are deprecated and considered legacy; they are not removed, but the documentation directs new code to the constructors . A codebase migrating to v4 should replace z.object({...}).strict() with z.strictObject({...}) and z.object({...}).passthrough() with z.looseObject({...}) .
c. The .strictObject() migration
The migration is a structural change, not a suffix replacement. The z.object() call and its argument move inside the new constructor, and the chained method disappears.
// BEFORE: v3 strict object (deprecated in v4)
const UserSchemaV3 = z.object({
name: z.string(),
email: z.email(),
}).strict();
// AFTER: v4 strict object constructor
const UserSchema = z.strictObject({
name: z.string(),
email: z.email(),
});
The same transformation applies to .passthrough() becoming z.looseObject(). A find-and-replace that only removes .strict() leaves the z.object() call in place, which silently reverts the schema to strip mode—the exact behavior the strict schema was meant to prevent. The migration must move the call site, which is why the guidance is to do it by hand or with a codemod rather than a regex .
The strict mode also affects composition. A strict object rejects unknown keys, which means it cannot be intersected with another object schema without the two shapes being mutually compatible. The Zod documentation notes that object schema intersections have unintuitive behavior because object schemas do not allow unknown keys by default; strict objects make this explicit rather than silent .
Complete Example Session
// ============================================
// PART 1: OPTIONAL VS DEFAULT
// ============================================
import { z } from "zod";
const UserSchema = z.object({
name: z.string(),
nickname: z.string().optional(), // may be absent
role: z.enum(["admin", "user"]).default("user"), // filled when absent
});
type UserInput = z.input<typeof UserSchema>;
// { name: string; nickname?: string; role?: "admin" | "user" }
type User = z.infer<typeof UserSchema>;
// { name: string; nickname?: string; role: "admin" | "user" }
// ============================================
// PART 2: DEFAULT STRIP MODE
// ============================================
const Stripped = z.object({ name: z.string() });
Stripped.parse({ name: "Ana", extra: 1 }); // { name: "Ana" }
// ============================================
// PART 3: STRICT MODE
// ============================================
const Strict = z.strictObject({ name: z.string() });
const result = Strict.safeParse({ name: "Ana", extra: 1 });
if (!result.success) {
console.log(result.error.issues[0].code); // "unrecognized_keys"
}
// ============================================
// PART 4: LOOSE MODE
// ============================================
const Loose = z.looseObject({ name: z.string() });
Loose.parse({ name: "Ana", extra: 1 }); // { name: "Ana", extra: 1 }
// ============================================
// PART 5: PARSING WITH DEFAULTS
// ============================================
UserSchema.parse({ name: "Ana" });
// { name: "Ana", role: "user" } — nickname absent, role filled
The parts covered optional versus default fields, the default strip mode, strict mode with error reporting, loose mode, and the parse result that fills defaults.
Quick Reference
Object Modes
| Constructor | Unknown Keys | Use For |
|---|---|---|
z.object() | Stripped | Public API bodies |
z.strictObject() | Rejected | Internal payloads, config |
z.looseObject() | Preserved | Passthrough envelopes |
Field Modifiers
| Modifier | Input Type | Output Type |
|---|---|---|
.optional() | T | undefined | T | undefined |
.nullable() | T | null | T | null |
.default(v) | T | undefined | T |
v3 to v4 Migration
| v3 Syntax | v4 Syntax |
|---|---|
z.object({}).strict() | z.strictObject({}) |
z.object({}).passthrough() | z.looseObject({}) |
.strip() | Default behavior |
Best Practices
✅ Do This:
// Use default object for public API bodies
z.object({ name: z.string() }) // ✅
// Use strict object for internal config
z.strictObject({ name: z.string() }) // ✅
// Use default for fallback values
role: z.enum(["admin", "user"]).default("user") // ✅
// Use optional for genuinely absent fields
nickname: z.string().optional() // ✅
❌ Don’t Do This:
// Expect strict behavior from default object
z.object({ name: z.string() }).parse({ extra: 1 }) // ❌ (strips)
// Use optional when a default is intended
role: z.string().optional() // app gets undefined // ❌
// Migrate strict with regex only
// z.object({...}).strict() → z.object({...}) // ❌ (reverts to strip)
// Use .strict() in new v4 code
z.object({}).strict() // ❌ (deprecated)
Common Pitfalls
| Pitfall | Why It Happens | Fix |
|---|---|---|
| Typo silently disappears | Default strip mode | Use z.strictObject() |
| Field missing at runtime | .optional() used instead of .default() | Use .default(v) |
| Strict mode reverted after migration | Only removed .strict() suffix | Use z.strictObject({...}) |
| Input type mismatch | Default makes output required | Use z.input for raw data |
Real-World Examples
1. Default strip object
z.object({ name: z.string() });
2. Strict object
z.strictObject({ name: z.string() });
3. Loose object
z.looseObject({ name: z.string() });
4. Optional field
bio: z.string().optional();
5. Default field
role: z.string().default("user");
6. Strict object with error
z.strictObject({ name: z.string() }).safeParse({ extra: 1 });
7. v3 to v4 migration
// z.object({}).strict() → z.strictObject({})
8. Input type for defaults
type Raw = z.input<typeof SettingsSchema>;
9. Output type after defaults
type Parsed = z.infer<typeof SettingsSchema>;
10. Loose passthrough
z.looseObject({ id: z.string() });
Visual
Three Object Modes
┌─────────────────────────────────────────────────────────┐
│ z.object({ name: z.string() }) │
│ INPUT: { name: "Ana", extra: 1 } │
│ OUTPUT: { name: "Ana" } ◄── extra stripped │
│ │
│ z.strictObject({ name: z.string() }) │
│ INPUT: { name: "Ana", extra: 1 } │
│ OUTPUT: ZodError: unrecognized_keys │
│ │
│ z.looseObject({ name: z.string() }) │
│ INPUT: { name: "Ana", extra: 1 } │
│ OUTPUT: { name: "Ana", extra: 1 } ◄── extra kept │
└─────────────────────────────────────────────────────────┘
Optional vs Default
┌─────────────────────────────────────────────────────────┐
│ .optional() .default("user") │
│ │
│ Input: undefined Input: undefined │
│ Output: undefined Output: "user" │
│ Type: T | undefined Type: T (output) │
│ Type: T | undefined (input) │
└─────────────────────────────────────────────────────────┘
Migration Structure
┌─────────────────────────────────────────────────────────┐
│ v3: z.object({ ... }).strict() │
│ │ │
│ ├── z.object() call │
│ └── .strict() chained │
│ │
│ v4: z.strictObject({ ... }) │
│ │ │
│ └── call moved inside constructor │
│ │
│ Removing .strict() alone reverts to strip mode │
└─────────────────────────────────────────────────────────┘
Summary
| Item | Value |
|---|---|
| Default object | z.object() strips unknown keys |
| Strict object | z.strictObject() rejects unknown keys |
| Loose object | z.looseObject() preserves unknown keys |
| Optional field | .optional() → T | undefined |
| Default field | .default(v) → output T, input T | undefined |
| v3 migration | .strict() → z.strictObject() |
| Deprecated | .strict(), .passthrough() |
Key takeaways:
- The default object strips unknown keys. A typo in a field name parses successfully and the value disappears. Use
z.strictObject()when that outcome is unacceptable . - Strict, loose, and strip are constructor choices in v4. The
.strict()and.passthrough()methods are deprecated;z.strictObject()andz.looseObject()are the canonical forms . - Optional and default are not interchangeable.
.optional()permits absence and infersundefined;.default()fills absence and infers the value on output . - The migration is structural. Removing
.strict()without moving the call toz.strictObject()silently reverts the schema to strip mode, which is the opposite of the intended change. - Default strip is right for public APIs. A client sending an extra field should not cause a 400. Strict is right for internal payloads where an unexpected key is a bug .
Remember: An object schema is not just a shape; it is a policy on unknown data. The default policy is to discard what is not declared, which is forgiving and dangerous at the same time. z.strictObject() changes the policy to rejection, which surfaces the typo at the boundary. .optional() and .default() change the policy on absence: one permits it, the other fills it. Choose each policy deliberately, and when migrating from v3, move the entire call to the new constructor rather than stripping the method from the end.
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!