Optional, nullable, defaulted

Three modifiers that look similar and mean three different things: .optional() lets the slot stay absent, .nullable() makes null a valid value, .default(x) fills the slot when input is missing.

Category
Conceptual
.optional()
inner | undefined
.nullable()
inner | null
.default(x)
inner, slot filled at mount

The demo runs the same field through all three modifiers side by side. Watch how each one reports its value when nothing has been typed, and how each one validates when you do start typing.

Optional & Nullable Demo Open in playground
ModifierInputform.values.<path>form.errors.<path>
.optional()undefined
.nullable()null
.default('seed')"seed"
z.string().min(1)""Name is required

Watch the empty case for each modifier: .optional() reports undefined; .nullable() reports null; .default('seed') reports the literal default; the plain required string reports '' and the refinement error fires.

The trichotomy at a glance

const schema = z.object({
  optional: z.string().optional(), // string | undefined
  nullable: z.string().nullable(), // string | null
  defaulted: z.string().default('seed'), // string
})

const form = useForm({ schema })

form.values.optional // undefined (slot may be absent)
form.values.nullable // '' (synthesized falsy: string default '')
form.values.defaulted // 'seed' (schema-declared default applied)

Each modifier sends a different signal at validate time:

ModifierEmpty case is valid?Empty case looks like
.optional()yesundefined
.nullable()yesnull
.default(x)yesx
(plain)no'' / 0 / false

.optional(): "this slot may be absent"

z.object({ bio: z.string().optional() })

form.values.bio reads string | undefined. The schema accepts both:

  • The slot being absent (the field never gets a value).
  • A typed string.

Use .optional() when not filling the field is a valid choice: a "tell us more about yourself" prompt that's genuinely skippable.

The empty-input cycle

When a user clears an .optional() input via v-register, storage returns to undefined rather than ''. The schema's "absent" path stays reachable through the DOM after any interaction, which closes a real validation hole: typing invalid text into an optional field with a strict inner check (e.g. z.url().optional()) and then clearing the input also clears the validation error. Without this contract, '' would land in storage and parse would fail forever ('' is neither undefined nor a valid URL), leaving the user stuck.

Optional Clear Cycle Demo Open in playground
Storageundefined
Validityvalid
  1. Leave it empty. Storage is undefined, the field is valid (the .optional() path accepts absence).
  2. Type not-a-url. Storage holds the typed string; validation reports the malformed URL.
  3. Clear the input (select + delete). Storage flips back to undefined; the error clears too.
  4. Type https://attaform.dev. Storage holds the full string; validation passes.

Without the optional-clear contract, step 3 would leave storage at '', which is neither undefined (the optional escape) nor a valid URL (the inner check). The error would stick around forever, and the only way out would be to refresh or type a valid URL over the now-invisible bad input. The contract makes the absent state reachable from the DOM.

Required string leaves keep '' on clear, because '' IS a valid string and the schema can decide what to do with it (accept, fail min(1), etc.). The optional escape only fires when the schema admits undefined at that leaf.

.nullable(): "null is an explicit signal"

z.object({ assignedTo: z.string().nullable() })

form.values.assignedTo reads string | null. The schema accepts:

  • null, meaning "explicitly unassigned."
  • A typed string.

But NOT undefined. The slot is required to hold some value; null is the signal you've decided "no value here on purpose."

Use .nullable() when unassigned is a meaningful state that the data model needs to distinguish from "not yet decided" or "empty string."

.default(x): "fill the slot with x when input is missing"

z.object({ priority: z.string().default('normal') })

form.values.priority reads string: no union, no null, no undefined. The schema:

  • Applies 'normal' when no input is supplied.
  • Otherwise uses whatever the caller passed.

Use .default(x) when every record needs a value and you have a sensible starting point: the "Priority" dropdown that should start at "Normal," the "Notifications" toggle that should start on.

Stacking modifiers

The modifiers compose, but the order changes the meaning:

z.string().optional().default('seed') // (string | undefined).default → string
z.string().default('seed').optional() // string.optional → string | undefined
z.string().nullable().default(null) // (string | null).default → string | null

For form ergonomics, .default(x) last is the usual move; it peels the optionality back off, so form.values.<path> reads as a concrete type.

What form.errors.<path> does for each

const schema = z.object({
  optional: z.string().optional(),
  nullable: z.string().nullable(),
  defaulted: z.string().default('seed'),
  required: z.string().min(1, 'Name is required'),
})
const form = useForm({ schema })

// Before any user input
form.errors.optional // undefined (empty slot is valid)
form.errors.nullable // undefined ('' satisfies z.string())
form.errors.defaulted // undefined ('seed' satisfies z.string())
form.errors.required // [...] (z.string().min(1) rejects '')

The errors flow from the schema; the modifiers shape what counts as empty. A required z.string() with no modifier sees '' and accepts it (an empty string is a string). Add .min(1, …) to require a non-empty string; that's the schema speaking, not a side-channel.

When to reach for unset instead

.optional() and .nullable() are schema-level; they declare that the empty case is type-valid. Sometimes you want a leaf that's type-required but starts in a deliberate "no value yet" state without changing the schema. That's unset:

import { unset } from 'attaform/zod'

const schema = z.object({ pickup: z.string().min(1, 'Required') })
const form = useForm({ schema, defaultValues: { pickup: unset } })

form.fields.pickup.blank // true
form.errors.pickup // [{ code: 'atta:no-value-supplied', … }]

unset joins the path to form.blankPaths, which surfaces atta:no-value-supplied reactively when the schema is required. The schema stays strict; the consumer signals intent at the call site. The same sentinel works at containers, arrays, records, discriminated unions, wrappers, and the root: defaultValues: { profile: unset } recursively marks every primitive descendant. See unset for the full contract and the blank field-state bit for the lifecycle.

Where to next