Register transforms
A pipeline of pure sync functions that runs over user input before the assigner writes to form state. Use it for trim / lowercase / mask / clamp normalisations — anything that should always apply no matter how the user typed.
Basic example
import type { RegisterTransform } from 'attaform'
const trim: RegisterTransform = (v) => (typeof v === 'string' ? v.trim() : v)
const lowercase: RegisterTransform = (v) => (typeof v === 'string' ? v.toLowerCase() : v)
// In setup
const rv = form.register('email', { transforms: [trim, lowercase] })
<input v-register="rv" />
<!-- type " Foo@BAR.com ", form.values.email === "foo@bar.com" -->
RegisterTransform is (value: unknown) => unknown —
generic-erased so the same trim works for every string path.
Type-safety at the call site is delegated to attaform's slim-primitive
gate at write time.
Pipeline ordering
DOM event → modifier cast → coerce → transforms[0] → … → transforms[n] → assigner
- Modifier cast —
.lazy/.trim/.numberfrom the directive itself. - Coerce — schema-driven type coercion (see coerce recipe).
- Transforms — your sync pipeline, left-to-right.
- Assigner — the default writer, or
@update:registerValueif you've attached one.
Combine freely:
<input v-register.lazy.number="register('age', { transforms: [clamp(0, 99)] })">
casts to a number on blur, clamps, then writes.
What transforms DON'T apply to
This is deliberately narrow — transforms are user-input normalisation, not storage middleware:
form.setValue(path, value)andrv.setValueWithInternalPath(value)— programmatic writes. Compose transforms yourself at the call site if you want the same normalisation:form.setValue('email', lowercase(trim(rawValue))).form.reset()/ hydration / SSR replay — those write canonical state that's already been validated; running normalisation over it would be redundant or destructive.markBlank()— already writes the slim default.
Composing with @update:registerValue
The override receives the post-transform value as its first arg. A consumer who declared transforms intended "always normalise"; a silent bypass when an override is attached would be the surprise.
If you want the raw extracted value, don't register transforms — use the override exclusively. If you want both pre- and post-transform inspection inside the override, register transforms and read the first arg.
Failure mode
Transforms must be sync. Attaform wraps each call in try/catch; on throw OR Promise return:
- The pipeline aborts (subsequent transforms don't run).
- Form state is NOT updated; the assigner returns
false. - The DOM's
:valuereactive binding round-trips form state back, snapping the input to the prior value (same UX as the documented "rejection" pattern). - A
console.erroris logged. In dev the message includes the path, transform index, transform.name, the original error, and a remediation hint. In prod the message is a single fixed string with NONE of those — transform bodies can construct error messages from user-typed values, throw with sensitive stack frames, or originate inside deeply-nested call chains we don't control.
A throw on one keystroke doesn't poison subsequent keystrokes (the next event runs the pipeline fresh) and doesn't affect other fields' assigners (each field has its own pipeline).
When to reach for @update:registerValue instead
Three patterns where the override pulls weight that transforms
doesn't:
- Rejection with side effect. The override receives the
RegisterValue; you can inspect, log to telemetry, then conditionally callrv.setValueWithInternalPathor skip. - Redirection. Write to a different field, multiple fields, or an external store using the form API.
- Custom DOM mutation. The override has access to the event
flow; you can synchronously rewrite
event.target.valueif your use case can't rely on the:valueround-trip.
Transforms cover normalisation. The override covers control.