The blank field-state bit
A storage / display side-channel for cleared numeric inputs. Storage holds
0, the user sees empty, and the form doesn't silently submit0for an unfilled required field.
- Category
- Field-state
- Auto-marks
- numeric primitives only (number, bigint)
- Manual opt-in
unset sentinel (any path)- Error code
atta:no-value-supplied
The demo shows four fields with different schemas. Watch the blank column and the errors column as you type. The numeric field starts blank-marked even though values.age === 0; the required string field uses the schema's refinement instead; the loose string field never raises an error; and the unset-defaulted country starts blank-marked deliberately, clearing as soon as you type.
Why it exists
The whole library obeys one principle: errors = f(schema, state). Storage plus the schema tell you whether the form is valid, except for one case.
Numeric inputs lie. A <input type="number"> whose value the user has just cleared shows '' in the DOM, but the slim shape requires a number, so storage holds 0. The schema can't tell the difference between "user typed 0" and "user supplied nothing": both produce 0 in storage. Without a side-channel, the runtime would either:
- Trust storage and silently submit
0for an unfilled required field (the public-housing-form footgun: "Income?$0. Approved."). - Re-define
0as "definitely blank," which loses the case where the user actually meant0.
blankPaths is the side-channel. It's a reactive BlankPathsView (Set-like: size, has(input), values(), Symbol.iterator) recording paths where the runtime knows storage and the visible display diverge. The schema author writes z.number() and gets the "empty input" signal back without inventing a sentinel value.
When blank auto-marks
The runtime auto-marks numeric leaves only. The asymmetry is real:
| Type | Storage slim default | DOM "empty" | Need the side-channel? |
|---|---|---|---|
number | 0 | '' | Yes: storage and display diverge. |
bigint | 0n | '' | Yes: same reason. |
string | '' | '' | No, they match byte-for-byte. |
boolean | false | unchecked | No, they match. |
For strings and booleans the schema sees what the user sees. Require non-empty strings via z.string().min(1): the refinement error fires the moment storage is '', schema speaking.
Lifecycle (numeric)
form mounts (no defaults)
→ blankPaths.add('income')
→ form.errors.income = [{ code: 'atta:no-value-supplied', … }]
→ form.fields.income.blank === true
user types "5"
→ blankPaths.delete('income')
→ form.errors.income = undefined
→ form.fields.income.blank === false
user clears the input (backspace)
→ directive sees el.value === ''
→ blankPaths.add('income')
→ form.errors.income re-appears reactively
→ form.fields.income.blank === true
user types "0"
→ blankPaths stays empty (the value is intentional)
→ form.errors.income stays undefined
errors = f(schema, state) holds at every step: state includes (form.value, blankPaths), and the function recomputes whenever either changes.
Lifecycle (string)
form mounts (no defaults)
→ blankPaths empty (strings don't auto-mark)
→ form.errors.email = undefined (z.string() accepts '')
→ form.fields.email.blank === false
user types "hi" then deletes
→ blankPaths still empty
→ form.errors.email still undefined (z.string() still accepts '')
→ form.fields.email.blank === false
If the schema is z.string().min(1) instead, the lifecycle is the same on blankPaths, but form.errors.email carries a refinement error whenever storage is '', because that's the schema speaking. The blank channel stays out of it.
Explicit opt-in at any path: the unset sentinel
Sometimes you do want a string or boolean leaf to start blank: a "please choose" indicator on a checkbox, a deferred-fill text field. That's an explicit consumer signal, not runtime inference. Use the unset sentinel:
import { unset, useForm } from 'attaform/zod'
useForm({
schema: z.object({ agreed: z.boolean(), note: z.string() }),
defaultValues: { agreed: unset, note: unset },
})
// Or imperatively:
form.setValue('agreed', unset)
form.reset({ note: unset })
unset works at every position the consumer can address: primitive leaves, containers, arrays, tuples, records, discriminated unions, optional / nullable wrappers, and the root. Container unset recurses through the schema's slim subtree and adds every primitive descendant to blankPaths in one call:
form.setValue('profile', unset) // marks profile.name, profile.age, etc.
form.reset({ cargo: unset }) // DU stub, marks the discriminator path
form.reset(unset) // root: marks every primitive leaf in the schema
Combined with required schemas, the sentinel surfaces a atta:no-value-supplied error reactively at each marked path: same lifecycle as the numeric auto-mark case, just driven by consumer intent rather than runtime inference. See the unset page for the position-by-position contract.
How to read blank in your UI
Attaform never renders. The signal is exposed; your component decides what to do.
<script setup lang="ts">
const form = useForm({ schema })
</script>
<template>
<input v-register="form.register('income')" />
<!-- show errors only after the user has touched the field -->
<p v-if="form.errors.income && form.fields.income.touched" class="error">
{{ form.errors.income[0].message }}
</p>
<!-- separately, an "unanswered" hint that distinguishes from errors -->
<span v-if="form.fields.income.blank" class="hint">Required, please enter a number</span>
</template>
Reading form.errors.income directly gives you whatever the schema and the blank channel produced. Reading form.fields.income.blank gives you the raw "did the user supply something?" bit, useful for pre-error indicators or progress meters.
Submit-time integration
handleSubmit checks blankPaths against the schema before running the success callback:
- If
blankPathsis non-empty AND the schema requires those paths, submission fails. The success callback never runs,meta.submissionAttemptsticks, andmeta.submitErrorcarries the aggregate. - If
blankPathsis non-empty but the schema accepts the empty case (.optional(),.nullable(),.default(x)), submission proceeds.
The atta:no-value-supplied error surfaces in form.errors.<path> and in form.meta.errors: same shape as a schema-emitted error, distinct code for filtering.
blank and history
Every history position captures the blankPaths set at the time of the snapshot. Undoing a "type a number, then clear it" sequence restores both the value AND the blank bit. The field reads as empty the way it did before the undo.
Where to next
- Defaults from the schema: auto-mark interacts with
defaultValues; explicit values turn it off. unset: flag any path blank indefaultValues,setValue, orreset.- Display state and showing errors:
firstErrorincludes theno-value-suppliedentry;getDisplayStatedecides when to render it.