Files
gruperly/.agents/skills/zod/references/object-optional-vs-nullable.md
2026-09-04 16:49:24 -03:00

3.6 KiB

title, impact, impactDescription, tags
title impact impactDescription tags
Distinguish optional() from nullable() MEDIUM-HIGH Confusing undefined and null semantics causes "property does not exist" vs "property is null" bugs; choose deliberately 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):

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):

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:

// 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:

// 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