Objects
The building block of APIs
Every JSON API sends and receives objects. A contact submission is an object. A user profile is an object. A list of search results is an object containing an array. We have been using z.object() since the first lesson, but there is a lot more to it than just grouping fields together.
This lesson covers the details: required vs optional fields, default values, how Zod handles extra fields, and how to make entire schemas optional or required in one step.
Every field is required by default
import { z } from "zod/v4";
const UserSchema = z.object({
name: z.string().min(1),
email: z.string().email(),
age: z.number().int().positive(),
}); Every field inside z.object() is required unless you explicitly make it optional. If a field is missing, validation fails:
UserSchema.parse({ name: "Alice", email: "[email protected]", age: 30 }); // passes
UserSchema.parse({ name: "Alice", email: "[email protected]" }); // fails: age is required
UserSchema.parse({ name: "Alice" }); // fails: email and age required This is a good default. You want to be explicit about which fields are optional rather than accidentally allowing missing data.
Making fields optional
You already know .optional() from the previous lesson. In the context of objects, it means “this field does not have to be present”:
const UserSchema = z.object({
name: z.string().min(1),
email: z.string().email(),
age: z.number().int().positive().optional(), // can be omitted
bio: z.string().max(500).optional(), // can be omitted
});
UserSchema.parse({ name: "Alice", email: "[email protected]" }); // passes: age and bio omitted Default values
What if you want a field to have a value even when the client does not send it? That is what .default() does:
const SettingsSchema = z.object({
theme: z.enum(["light", "dark"]).default("light"),
pageSize: z.number().int().min(1).max(100).default(20),
notifications: z.boolean().default(true),
});
SettingsSchema.parse({});
// returns { theme: "light", pageSize: 20, notifications: true }
SettingsSchema.parse({ theme: "dark" });
// returns { theme: "dark", pageSize: 20, notifications: true } Default values fill in anything that is missing. But if the field is provided, it still gets validated. { pageSize: 200 } fails because 200 exceeds max(100). The default only kicks in when the field is absent, not when it is invalid.
This is the difference between .optional() and .default(). With .optional(), a missing field stays undefined. With .default(), a missing field gets a real value. If your code cannot handle undefined, use .default().
Extra fields: strip, strict, passthrough
What happens when the client sends fields that are not in your schema? By default, Zod strips them:
const schema = z.object({ name: z.string() });
schema.parse({ name: "Alice", extra: "ignored" });
// returns { name: "Alice" } — "extra" is removed This is usually what you want for API input. The client might send extra fields (maybe a newer version of the frontend sends fields your backend does not know about yet), and silently dropping them is safe.
But sometimes you want different behavior. If you want to reject extra fields, use .strict():
const schema = z.object({ name: z.string() }).strict();
schema.parse({ name: "Alice", extra: "rejected" });
// throws: "Unrecognized key(s) in object: 'extra'" And if you want to keep extra fields, use .passthrough():
const schema = z.object({ name: z.string() }).passthrough();
schema.parse({ name: "Alice", extra: "kept" });
// returns { name: "Alice", extra: "kept" } For most API work, the default stripping behavior is what you want. Use .strict() when you need to be defensive about unexpected data. Use .passthrough() when you are proxying data and need to forward unknown fields.
partial() and required()
Here is a pattern that comes up constantly in APIs. You have a “create” endpoint where all fields are required, and an “update” endpoint where the client only sends the fields they want to change. You could write two separate schemas, but .partial() does it for you:
const UserSchema = z.object({
name: z.string(),
email: z.string().email(),
age: z.number(),
});
const PartialUser = UserSchema.partial();
// All fields are now optional: { name?: string, email?: string, age?: number }
PartialUser.parse({}); // passes: everything is optional And .required() goes the other direction, making everything required:
const RequiredUser = PartialUser.required();
// All fields are required again The create schema requires all fields. The update schema uses .partial() so the client only sends what changed. One source of truth, two schemas. We will use this pattern heavily when we get to schema composition.
Next up: nested objects. Real-world data is rarely flat. Addresses live inside orders, preferences live inside users, and Zod handles all of it.
Exercises
Exercise 1: Create a schema with 3 required fields and 2 optional fields. Test with various combinations.
Exercise 2: Add .default() to a field. Parse an object without that field. Verify the default is used.
Exercise 3: Parse an object with extra fields using default (stripped), .strict() (rejected), and .passthrough() (kept). Compare the results.
What does Zod do with extra fields that are not in the schema?