attaform

The framework-agnostic core. Use this if you're bringing your own schema library or wiring SSR by hand.

import {
  createAttaform,
  useForm, // re-export of useAbstractForm
  injectForm,
  useRegistry,
  renderAttaformState,
  hydrateAttaformState,
  escapeForInlineScript,
  vRegister,
  canonicalizePath,
  parseApiErrors,
} from 'attaform'

createAttaform(options?)

The Vue plugin. Install once per app.

createApp(App).use(createAttaform()).mount('#app')

Options:

FieldTypeDescription
overridebooleanForce isSSR to true / false. Auto-detected otherwise.
devtoolsbooleanEnable the Vue DevTools plugin. Default true. See recipe.
defaultsAttaformDefaultsApp-level option defaults applied to every useForm call. See recipe.

useForm<Form>({ schema, key, ... })

Schema-agnostic. Takes any AbstractSchema<Form, Form> — wrap a Valibot schema, ArkType schema, or a hand-rolled validator with a custom adapter. The Zod subpaths are pre-made wrappers over this. For options, see attaform/zod.

injectForm<Form>(key?)

Reach the nearest ancestor's form (no key) or reach any form by its key. Type-identical return to useForm. See recipe.

Resolution rules (no-key form):

  • Closest ambient ancestor wins.
  • Only anonymous useForm() (no key) fills the ambient slot; keyed forms are reachable only via injectForm(key).
  • No ambient ancestor → returns null (dev-mode warn).
  • Inherits the resolved ancestor's formInstanceId.

Resolution rules (keyed form): registry lookup by string key, independent of component-tree position.

useRegistry()

Returns the current app's AttaformRegistry. Must be called inside a component's setup().

renderAttaformState(app) → SerializedAttaformState

Server-side: serialize every form in the app to a plain object safe for JSON.stringify. Pair with hydrateAttaformState on the client.

hydrateAttaformState(app, payload)

Client-side: rehydrate forms from the serialized payload. Call before app.mount(...).

escapeForInlineScript(json) → string

Takes a JSON string and escapes the characters that would let a form value break out of an inline <script> tag: <, >, &, U+2028, U+2029. Pair with renderAttaformState when hand-rolling SSR; Nuxt handles it for you via devalue.

const payload = escapeForInlineScript(JSON.stringify(renderAttaformState(app)))
// `<script>window.__STATE__ = ${payload}</script>` is safe to inline.

vRegister

The v-register directive. Registered automatically by createAttaform; exported for consumers installing directives manually.

Bind to a native input, select, textarea, checkbox, or radio:

<input v-register="form.register('email')" />
<select v-register="form.register('country')">...</select>

Or to a custom component whose root is not a native input — useRegister() in the child reads the parent's binding so you can re-bind v-register onto an inner native element. When the wrapper's root is the input itself, Vue's attribute fallthrough handles it and useRegister is unnecessary.

<!-- Parent -->
<MyField label="Email" v-register="form.register('email')" />

<!-- MyField.vue (root is <label>, not <input>) -->
<script setup lang="ts">
  import { useRegister } from 'attaform'
  defineProps<{ label: string }>()
  const register = useRegister()
</script>

<template>
  <label class="field">
    <span>{{ label }}</span>
    <input v-register="register" />
  </label>
</template>

Modifiers

v-register mirrors Vue's v-model modifier semantics, scoped per element type. Modifier names are typed — a typo (v-register.lazi) is a TypeScript error, not a silent runtime no-op.

ElementModifierWhat it does
<input type="text">, <input type="number">, <textarea>.lazyWrite on change (blur) instead of input. Disables IME composition handlers — composition events don't gate writes.
<input type="text">, <input type="number">, <textarea>.trimStrip leading/trailing whitespace on blur. While the user is typing, the model holds the raw input (whitespace included); on change the value is trimmed once and written to both model and DOM. Combine with .lazy to skip the mid-typing writes entirely.
<input type="text">, <input type="number">, <textarea>.numberCast via parseFloat before writing; values that can't be parsed pass through unchanged. Auto-applied for <input type="number"> — explicit .number is redundant.
<select>.numberCast each selected option's value via parseFloat before writing. Mirrors Vue's v-model on <select>.
<input type="checkbox">, <input type="radio">(none)No modifiers — Vue's v-model doesn't define any here either.

Combine freely on text/textarea: <input v-register.lazy.number="form.register('age')" />.

When the slim-primitive gate rejects a write produced by a modifier cast (e.g. .number × 'abc' against a z.number() slot — the non-parseable string passes through looseToNumber unchanged), the directive's listener completes silently and the DOM keeps the user's input. The form state stays at its previous value. Field-level validation will surface a refinement error on the next render.

Custom assigners — @update:registerValue

Replaces the directive's default "DOM event → extract value → rv.setValueWithInternalPath(value)" bridge. The handler receives the post-extraction value plus the RegisterValue and decides what (if anything) reaches form state.

<script setup lang="ts">
  import type { RegisterValue } from 'attaform'

  const form = useForm({ schema, defaultValues: { username: '' } })

  function uppercaseAssigner(value: unknown, rv: RegisterValue): void {
    rv.setValueWithInternalPath(String(value ?? '').toUpperCase())
  }
</script>

<template>
  <input v-register="form.register('username')" @update:registerValue="uppercaseAssigner" />
</template>

Modifier extraction runs first — .number gives you a number, .trim the trimmed string, <input type="checkbox"> the boolean.

Four patterns:

  • Transform — call rv.setValueWithInternalPath(normalised).
  • Reject — skip the call; the keystroke drops entirely (distinct from validation errors, which accept then flag).
  • Side-effect + default — log / analytics, then call through.
  • Redirect — write to a different field or external store.

Handler signature: (value: unknown, registerValue: RegisterValue) => boolean | undefined. Return false to flag a rejected write; undefined / void is success. Use only on <input>, <select>, <textarea> roots — for non-form roots see useRegister() or assignKey (Web Components).

The handler can be a top-level function outside setup() since rv is supplied by the directive. Multiple listeners on the same element receive (value, rv) in registration order.

Transforms — register(path, { transforms: [...] })

A pipeline of pure functions for normalizing user input. Composed left-to-right; runs inside the directive's assigner across every v-register element variant.

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)

const rv = form.register('email', { transforms: [trim, lowercase] })
<input v-register="rv" />
<!-- type "  Foo@BAR.com  ", form receives "foo@bar.com" -->

RegisterTransform is (value: unknown) => unknown — generic-erased so a personal library of transforms plugs into any register() slot. Write defensive bodies that no-op on type mismatch.

Pipeline ordering: transforms run after modifier extraction, before the assigner writes to form state.

DOM event → modifier cast → transforms[0] → … → transforms[n] → assigner

Combine freely: <input v-register.lazy.number="register('age', { transforms: [clamp(0, 99)] })">.

Scope. Transforms apply to user-input via the directive only — NOT to setValue, reset, hydration, SSR replay, or markBlank(). For programmatic writes, compose transforms at the call site: form.setValue('email', lowercase(trim(rawValue))).

With @update:registerValue. The override receives the post-transform value as its first arg. If you want the raw extracted value, don't register transforms.

Failure mode. Must be sync. On throw OR Promise return: the pipeline aborts, form state is unchanged, the assigner returns false, the DOM reverts via the :value binding, and a console.error is logged. Dev mode includes the path, transform index, transform .name, and remediation hint; prod logs a fixed string only. A throw on one keystroke doesn't poison subsequent keystrokes or other fields.

Transforms cover normalization. @update:registerValue covers control (rejection-with-side-effect, redirection, custom DOM mutation).

canonicalizePath(input) → { segments, key }

Normalise a dotted-string or array path into a structured Path plus a stable PathKey. Use when building custom adapters.

parseApiErrors(payload, options) → ParseApiErrorsResult

Pure transformation: takes a server response in the common shapes ({ error: { details } }, { details }, or a raw { path: entry } record) and returns { ok, errors, rejected? }. Pair with form.setFieldErrors(result.errors) to apply.

Wire format. Two entry shapes:

  • Structured{ message: string, code: string }. code forwards onto the produced ValidationError.
  • Bare string — synthesized into { message, code: defaultCode }. defaultCode defaults to 'api:unknown'.

A field's value may be a single entry, an array, or a mix.

{
  "error": {
    "details": {
      "email": { "message": "taken", "code": "api:duplicate-email" },
      "password": [{ "message": "too short", "code": "api:min-length" }, "must include a number"],
      "username": ["Username is reserved."],
      "items.0.name": { "message": "blank", "code": "api:blank" },
      "": { "message": "form-level failure", "code": "api:form" },
    },
  },
}
const result = parseApiErrors(response, {
  formKey: form.key,
  // Stamp every bare-string entry with a custom code (default 'api:unknown'):
  defaultCode: 'api:server-validation',
  // Optional caps for untrusted gateway-passthrough payloads:
  maxEntries: 200, // default 1000
  maxPathDepth: 8, // default 32
})
if (result.ok) form.setFieldErrors(result.errors)
else console.warn('Bad payload:', result.rejected)

Half-structured entries ({ message } with no code, or { code } with no message) are still rejected — those signal a server bug (the wire shape was trying to be structured) and shouldn't be silently coerced.

See server-errors recipe for the full pattern.

Error codes

Every ValidationError carries a required code: string for stable machine identification. Convention is <scope>:<kebab-case>:

ScopeOwnerExamples
atta:Library coreatta:no-value-supplied, atta:adapter-threw, atta:path-not-found
zod:Zod adapterzod:too_small, zod:invalid_format, zod:custom (forwarded from issue.code)
consumerYour app / backendapi:duplicate-email, auth:expired-token, myapp:account-locked

The library exports AttaformErrorCode for branching on internal codes:

import { AttaformErrorCode } from 'attaform'
// or 'attaform/zod' / 'attaform/zod-v3'

if (error.code === AttaformErrorCode.NoValueSupplied) {
  // user opened the form and hasn't filled this field yet
}
if (error.code.startsWith('zod:')) {
  // schema-level validation failure
}

zod: codes are computed inline (no enum) since Zod's code list evolves. String-match the prefix to handle "any zod error" generically, or check exact codes for fine-grained branching.

The library never invents consumer-side codes — they originate in your backend payload (via parseApiErrors) or in setFieldErrors / addFieldErrors calls you make directly. Pick a prefix and stay consistent across your app.

unset

A brand-typed sentinel symbol used to mark a primitive leaf as displayed-empty while storage holds the schema's slim default (0 for z.number(), '' for z.string(), false for z.boolean(), 0n for z.bigint()).

import { unset, useForm } from 'attaform/zod'
import { z } from 'zod'

const form = useForm({
  schema: z.object({ income: z.number() }),
  defaultValues: { income: unset }, // input renders blank, storage = 0
})

// Programmatic clear — same semantic as the user backspacing the field.
form.setValue('income', unset)

// Restore-with-blanks via reset.
form.reset({ income: unset })

Three places accept the sentinel:

  • defaultValues — every primitive leaf can be unset. The library walks the payload at construction and adds the leaf's path to the form's blankPaths set.
  • setValue(path, unset) — translated at the API boundary; storage gets the slim default with blank: true meta.
  • reset({ … }) — same translation; the post-reset state becomes the new dirty=false baseline.

Auto-mark on construction. Every primitive leaf the consumer didn't supply in defaultValues is auto-marked blank. To opt a leaf out, supply a non-unset value (defaultValues: { email: '' }). Auto-mark recurses through nested objects, NOT arrays. Hydration (persisted draft, SSR payload) overrides — the hydrated blankPaths list is authoritative.

Submit / validate honor the sentinel. A blank path bound to a required schema raises "No value supplied" during handleSubmit / validate*. Optional / nullable / has-default schemas accept the empty case.

The directive's input listener auto-marks numeric inputs on empty DOM; strings and booleans require explicit unset (DOM state alone doesn't carry "user-cleared" intent).

Introspection. form.fields.<path>.blank per-path; form.blankPaths.value (frozen ReadonlySet<PathKey>) for bulk. isUnset(value) is the runtime guard; Unset the type-level brand.

Other exports

  • parseDottedPath(s) — string → Segment[]
  • assignKeyunique symbol used to install a custom assigner on a v-register-bound element. For most cases prefer the @update:registerValue listener (see Custom assigners); reach for assignKey only when you need pre-mount installation (typically Web Components).
  • isRegisterValue(x) — type guard for the object register returns
  • RegisterTransform(value: unknown) => unknown — type alias for entries in register(path, { transforms: [...] }). Generic-erased so a personal library of transforms works across any path type; see Transforms.
  • ROOT_PATH / ROOT_PATH_KEY — the empty path and its key
  • PARSE_API_ERRORS_DEFAULTS{ maxEntries: 1000, maxPathDepth: 32, maxTotalSegments: 10000 } constant
  • AnonPersistError / InvalidPathError / OutsideSetupError / RegistryNotInstalledError / ReservedFormKeyError / SensitivePersistFieldError / SubmitErrorHandlerError — error classes