--- title: Distinguish optional() from nullable() impact: MEDIUM-HIGH impactDescription: Confusing undefined and null semantics causes "property does not exist" vs "property is null" bugs; choose deliberately tags: object, optional, nullable, undefined --- ## Distinguish optional() from nullable() `.optional()` allows `undefined` (field can be missing), while `.nullable()` allows `null` (field must be present but can be null). Choosing the wrong one causes subtle bugs in database operations, JSON serialization, and API contracts. **Incorrect (confusing optional and nullable):** ```typescript import { z } from 'zod' const userSchema = z.object({ name: z.string(), // Intended: field might not exist nickname: z.string().nullable(), // Wrong! Requires field to be present // Intended: field exists but might be null deletedAt: z.date().optional(), // Wrong! Allows field to be missing }) // This fails - nickname is required userSchema.parse({ name: 'John' }) // ZodError: Required at "nickname" // This passes but loses semantic meaning userSchema.parse({ name: 'John', nickname: null, deletedAt: undefined }) // Is deletedAt undefined because not deleted, or because data is incomplete? ``` **Correct (using optional and nullable deliberately):** ```typescript import { z } from 'zod' const userSchema = z.object({ name: z.string(), // optional() - field might not exist in the object nickname: z.string().optional(), // Type: string | undefined // nullable() - field must exist, but value can be null deletedAt: z.date().nullable(), // Type: Date | null }) // Field can be omitted userSchema.parse({ name: 'John', deletedAt: null }) // Valid // Field must be present (even if null) userSchema.parse({ name: 'John', nickname: 'Johnny' }) // ZodError: Required at "deletedAt" // Correct usage userSchema.parse({ name: 'John', nickname: 'Johnny', // Or omit entirely deletedAt: null, // Must be present, null means "not deleted" }) ``` **When to use each:** ```typescript // optional() - field may not exist // Use for: Optional form fields, sparse updates, optional config z.object({ bio: z.string().optional(), // User might not have filled this middleName: z.string().optional(), // Not everyone has one }) // nullable() - field exists but value can be null // Use for: Database nullable columns, "cleared" values, explicit absence z.object({ deletedAt: z.date().nullable(), // null = not deleted, Date = when deleted parentId: z.string().nullable(), // null = root node, string = has parent approvedBy: z.string().nullable(), // null = pending, string = approver }) // nullish() - either undefined or null // Use for: Lenient APIs, legacy data, optional nullable DB columns z.object({ legacyField: z.string().nullish(), // string | null | undefined }) ``` **API response patterns:** ```typescript // API includes null for "no value" (good for explicit absence) const apiResponseSchema = z.object({ data: z.object({ user: z.object({ name: z.string(), avatar: z.string().nullable(), // null = no avatar set }).nullable(), // null = user not found }), }) // Type: { data: { user: { name: string; avatar: string | null } | null } } // Partial updates send only changed fields const updateSchema = z.object({ name: z.string().optional(), // Omitted = don't change avatar: z.string().nullable().optional(), // null = clear avatar }) ``` **When NOT to use this pattern:** - When interacting with systems that treat null and undefined as equivalent - When using nullish() for maximum flexibility is acceptable Reference: [Zod API - optional/nullable](https://zod.dev/api#optional)