unset
A consumer-side shorthand for "this path starts blank." The runtime writes the schema's slim value at that path, joins every primitive descendant to
form.blankPaths, and the bound input renders empty until the user types.
- Category
- Export
- From
attaform · attaform/zod · attaform/zod-v3 · attaform/zod-v4- Use
defaultValues, setValue, reset- Read state
form.blankPaths, form.fields(path).blank
The demo carries a primitive leaf (email) and a container (profile with name + age). Click setValue('email', unset) to flag the leaf blank, or setValue('profile', unset) to flag the whole container blank. The panel shows storage (always concrete, never the sentinel) and the live blankPaths set.
What unset does
unset is a sentinel symbol exported from every entry point. Pass it as a value in defaultValues, setValue, or reset. The runtime translates it into two effects at the targeted path:
- Storage receives the schema's slim concrete. For primitive leaves that's
'',0,0n,false. For.optional()wrappers it'sundefined; for.nullable()it'snull. For container paths the runtime recurses: an object writes a shape with every leaf at its slim, an array writes[], a tuple writes its slim positions, a record writes{}, a discriminated union writes{ <discriminatorKey>: '' }with no variant body. form.blankPathsgains every primitive descendant under the target. Thev-registerdirective reads from the same set when binding the DOM input, so the field renders empty even though storage holds a concrete value.
Reads through form.values.<path> always see the slim value. Storage never holds the sentinel symbol.
Where unset can land
The sentinel works at every position the consumer can address:
import { unset, useForm } from 'attaform/zod'
const schema = z.object({
email: z.string(),
profile: z.object({ name: z.string(), age: z.number() }),
tags: z.array(z.string()),
cargo: z.discriminatedUnion('kind', [
z.object({ kind: z.literal('boat'), length: z.number() }),
z.object({ kind: z.literal('truck'), payload: z.number() }),
]),
})
const form = useForm({
schema,
defaultValues: {
email: unset, // primitive leaf
profile: unset, // container, marks profile.name and profile.age
tags: unset, // array, writes []
cargo: unset, // DU, writes { kind: '' } stub, no variant body
},
key: 'demo',
})
The same value works on every imperative write:
form.setValue('profile', unset) // re-blank the whole profile sub-tree
form.setValue('cargo', unset) // reset cargo to a no-variant-selected state
form.reset({ tags: unset, email: unset }) // baseline that re-applies on reset()
Root-level unset is admitted too. defaultValues: unset or form.reset(unset) walks the whole schema, marking every primitive descendant blank. Useful for "the user must touch every field" workflows where presence carries audit weight: a housing application form that needs to record "client manually set income to 0" separately from "income defaulted to 0."
Container behavior, in detail
| Position | Storage write | blankPaths adds |
|---|---|---|
| Primitive leaf | Schema's slim primitive | The leaf path |
| Bare object | Recursive slim subtree | Every primitive descendant under the path |
| Array / tuple / record | [] / slim tuple / {} | The container path itself |
| Discriminated union container | { <discriminatorKey>: <kind-blank> } | The discriminator's path |
.optional() wrapper | undefined | The wrapper path |
.nullable() wrapper | null | The wrapper path |
| Date / RegExp / Map / Set leaf | The schema's slim concrete | The leaf path |
The container path itself does NOT enter form.blankPaths. form.fields('profile').blank derives reactively from the conjunction "every primitive descendant is blank," so an empty container reads blank by vacuous truth, and one descendant filled flips the container's blank false automatically.
Reading the blank state
form.values.<path> returns concrete storage. Read the displayed-empty state through form.fields:
form.fields.email.blank // true after setValue('email', unset)
form.fields('profile').blank // true when every descendant of profile is blank
form.blankPaths.value exposes a BlankPathsView (Set-like: size, has(input), values(), plus a Symbol.iterator) for callers that want the whole list (persistence, debug overlays). The v-register directive reads the same signals and renders the DOM empty.
Required schemas raise no-value-supplied
A required leaf sitting in form.blankPaths surfaces an error in form.errors:
const schema = z.object({ income: z.number() }) // required
const form = useForm({ schema, defaultValues: { income: unset } })
form.errors.income // [{ code: 'atta:no-value-supplied', … }]
form.fields('income').blank // true
.optional(), .nullable(), and .default(N) schemas accept the empty case. Those leaves still sit in form.blankPaths (the directive needs the signal to render the input empty), but no error fires. See the blank field-state bit for the full lifecycle.
Cross-entry availability
unset and isUnset ship from every entry point: attaform, attaform/zod, attaform/zod-v3, attaform/zod-v4. The Unset type is exported alongside for explicit type annotations:
import { unset, isUnset, type Unset } from 'attaform/zod'
function maybeBlank(v: string | Unset): string | undefined {
return isUnset(v) ? undefined : v
}
isUnset is a type guard for explicit narrowing of value: T | Unset payloads (input parameters, setValue callbacks). It is NOT a form.values check: storage never holds the symbol, so isUnset(form.values.<path>) always returns false.
Where to next
- The
blankfield-state bit: the storage / display side-channelunsetplugs into. setValuepatterns: the imperative write surface.- Defaults from the schema: how schema-declared defaults and
unsetinteract at mount time.