Schema-driven coercion
Two built-in rules handle 90 % of real forms: string → number and string → boolean. Compose more when your schema needs them.
- Category
- Directive layer
- Defaults
- string → number · string → boolean
- Option
useForm({ coerce })- Registry type
readonly CoercionEntry[]
Type a number into the count field and watch it land in storage as a number (not a string). Type "true" or "false" into enabled and see it commit as a boolean. Both inputs are type="text"; coercion is what makes z.number() and z.boolean() leaves work against plain text inputs at the directive layer.
The default registry
defaultCoercionRules = [
{ input: 'string', output: 'number', transform: parseNumber },
{ input: 'string', output: 'boolean', transform: parseTrueFalse },
]
- string → number trims whitespace, parses with
Number(), returnscoerced: falseonNaN(the slim gate then rejects the unparseable value with a friendly message). Whitespace-only inputs skip the coercion so blank-paths machinery stays in charge. - string → boolean lowercases + trims, accepts
'true'/'false'in any case ('True','FALSE',' true '). Anything else skips so the gate can reject.
The same two rules cover most native HTML input shapes: <input type="number"> (number leaf), <input type="checkbox" value="..."> (boolean leaf), <select> with numeric option values.
When coercion fires
Coercion runs only on user-typed DOM values. Programmatic writes through form.setValue, form.register('path').setValueWithInternalPath, or the field-array helpers are never coerced; they're typed against the schema's leaf type at the call site, so the value already matches. The strictness is intentional: if you've got the value in hand in code, you knew its type when you typed it.
The coercion step sits between the directive's value extraction and the slim-type gate's write check:
DOM event → extract → modifier (.trim, .number) → transforms[] → coerce → slim gate → storage
A value the registry can't coerce (coerced: false) passes through unchanged; the slim gate handles the rejection downstream with a typed diagnostic.
<input type="file"> inputs skip coercion entirely; File handles are objects, not strings, and land in storage as-is.
Extending the default registry
useForm({ coerce }) accepts three forms:
useForm({ coerce: true }) // defaults (string→number, string→boolean)
useForm({ coerce: false }) // no coercion; slim gate rejects mismatches as-is
useForm({ coerce: [...defaultCoercionRules, defineCoercion({ ... })] })
Spread defaultCoercionRules to extend; pass a bare array to replace entirely. Adding a string → Date rule for ISO timestamps:
import { useForm } from 'attaform/zod'
import { defaultCoercionRules, defineCoercion } from 'attaform'
useForm({
schema,
coerce: [
...defaultCoercionRules,
defineCoercion({
input: 'string',
output: 'date',
transform: (s) => {
const d = new Date(s)
return Number.isFinite(d.getTime()) ? { coerced: true, value: d } : { coerced: false }
},
}),
],
})
Now <input type="text" v-register="form.register('publishedAt')" /> against a z.date() leaf works without modifiers.
App-wide defaults
Set coercion at the plugin level so every form picks up the same custom rule without per-form opt-in:
import { createAttaform } from 'attaform/zod'
import { defaultCoercionRules, defineCoercion } from 'attaform'
createAttaform({
defaults: {
coerce: [
...defaultCoercionRules,
defineCoercion({
/* ... */
}),
],
},
})
Per-form useForm({ coerce }) overrides the plugin default. The plugin default overrides Attaform's built-in default (defaultCoercionRules). Three layers, deterministic resolution.
Sync, no throws
Coercion rules MUST be sync. They SHOULD NOT throw; wrap internal try / catch when the conversion can fail (e.g. BigInt('not-a-number') throws for non-numeric strings). Attaform wraps each invocation in try / catch as defense in depth; throws are caught, logged once per (input, output) pair, and the original value passes through to the slim gate.
Where to next
- Modifiers:
.numberfor the<input type="text">+ numeric-leaf combo without touching the registry. - Register transforms: the per-field write pipeline that runs before coercion.
form.setValue: the programmatic-write surface that bypasses coercion entirely.- The
v-registerdirective: the layer coercion plugs into.