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.

Both inputs are type="text". The default coercion registry handles string → number and string → boolean automatically.

The default registry

defaultCoercionRules = [
  { input: 'string', output: 'number', transform: parseNumber },
  { input: 'string', output: 'boolean', transform: parseTrueFalse },
]
  • string → number trims whitespace, parses with Number(), returns coerced: false on NaN (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