118 lines
3.6 KiB
Markdown
118 lines
3.6 KiB
Markdown
---
|
|
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)
|