.md

Type safety

Wide while the user is typing, tight the moment the schema validates. Two surfaces, one promise: what you see is what is actually there.

Category
Concept
In-flight
form.values, defaultValues, setValue
Validated
handleSubmit((values) => ...)
Boundary
Schema parse

A form library has to tell the truth about what is in the form. Mid-typing, that is whatever the user has put there so far: an incomplete email, an undecided radio pick, a partially-filled subtree rehydrated from yesterday's session. Post-submit, it is the Zod schema's parsed output: every refinement honored, every literal narrowed, every transform applied.

Attaform's types model both states. form.values, defaultValues, and form.setValue use the in-flight shape, wide enough to hold whatever the form is actually carrying. handleSubmit((values) => ...) hands you the validated shape: narrow, exact, what the schema promised.

In-flight vs. validated types Open in playground
Transport

The gateway example: email

Take the simplest schema:

const schema = z.object({
  email: z.email('Enter a valid email'),
})
const form = useForm({ schema })

What is the type of form.values.email? string. Not a branded Email, not string & { __brand: 'email' }. Plain string. The reason is that during form completion, form.values.email legitimately holds "a", then "andy", then "andy@". None of those satisfy z.email(), and the schema knows that. That is why form.errors.email populates while the user is mid-typing.

If form.values.email were typed as a branded Email, the type would lie about what is actually there. The runtime value would be "andy@"; the static type would claim it is a valid email. The compiler cannot help you when it has been told a fiction.

So the type follows reality: while the user is typing, email is whatever string they have typed. After the schema parses successfully inside handleSubmit, it is whatever the schema promises.

Discriminators do the same thing

const schema = z.object({
  transport: z.discriminatedUnion('kind', [
    z.object({ kind: z.literal('boat'), hullLengthM: z.number() }),
    z.object({ kind: z.literal('truck'), payloadKg: z.number() }),
  ]),
})
const form = useForm({ schema })

form.values.transport.kind // string (not 'boat' | 'truck')

Same reasoning, one layer up. The user may not have picked a variant yet. They may be mid-keystroke in an input wired to the discriminator. They may be rehydrating a draft that was saved with kind: ''. Typing transport.kind as 'boat' | 'truck' would lie about every one of those cases.

The literal narrowing engages where it earns its keep: inside handleSubmit, after the schema confirms the discriminator is one of the variants. There, values.transport.kind is 'boat' | 'truck', and TypeScript narrows values.transport to the matching variant.

defaultValues works the same way

const form = useForm({
  schema,
  defaultValues: {
    email: '', // empty string is fine, even though z.email() will reject it
    transport: { kind: 'boat', hullLengthM: 0 }, // any starting state, even invalid
  },
})

Rehydration is the killer use case. Yesterday's user opened the form, picked a variant, typed half an email, then closed the tab. Today's defaultValues (re-read from localStorage, sessionStorage, or your server) needs to faithfully restore that half-filled state. If defaultValues.email required a value that already passed z.email(), you could not rehydrate the "still typing" case at all. You would be forced to invent a valid email the user never typed, or wipe the field entirely and lose their work.

Wide in-flight types mean you can store, hydrate, autosave, and resume from any intermediate state. The schema is still in charge of what counts as valid; the types just acknowledge that the form's job is to get there, not to always be there.

Where the types tighten

handleSubmit is the boundary:

const onSubmit = form.handleSubmit((values) => {
  values.email // string (validated, passed z.email())
  values.transport.kind // 'boat' | 'truck'

  if (values.transport.kind === 'boat') {
    values.transport.hullLengthM // narrowed to number
  } else {
    values.transport.payloadKg // narrowed to number
  }
})

The argument to your success callback is z.infer<typeof schema>, Zod's parsed output. Every literal is preserved, every refinement is honored, every transform is applied. If the schema did not parse cleanly, the success callback never fires; the error callback runs instead with the in-flight values for you to inspect.

That is the contract: while the form is being filled in, the types help you handle every shape it might be in. The moment validation succeeds, the types snap to what the schema actually guarantees.

Why not narrow earlier?

A library that narrowed form.values.email to a branded Email (or form.values.transport.kind to 'boat' | 'truck') before validation would have to do one of two things, both bad:

  • Refuse to type partial state. Empty strings, half-typed emails, undecided discriminators are all invalid against the schema. Either form.values could not represent them (the form cannot work) or the types would lie (the compiler cannot help).
  • Force consumers to cast. Every read becomes as string, every assignment becomes a fight with the type checker. The schema's value as a source of truth evaporates because you are routing around it on every line.

Wide in-flight types skip both failure modes. The schema still owns what counts as valid; the form just acknowledges that getting to valid is the user's job, not the type system's.

Paths are typed too

The other half of the contract is the path you hand to register, setValue, toRef and the rest. Every one of them is checked against the schema, and the rule is the same everywhere: a path is checkable when every segment carries a type.

A dynamic segment is fine as long as its type says where it sits. An array index is number, a z.record key is string, and both are real segments:

import { useForm } from 'attaform'
import { z } from 'zod'

const schema = z.object({
  boxes: z.array(z.object({ note: z.string() })),
  prefs: z.record(z.string(), z.boolean()),
})
const form = useForm({ schema, key: 'typed-paths' })

declare const i: number
declare const key: string

form.register(`boxes.${i}.note`)
form.register(`prefs.${key}`)
form.register(['boxes', i, 'note'])

What breaks the chain is a segment typed as bare string standing in for a container: concatenating one widens the whole path to string, and there is nothing left to match against. The fix is upstream of the call that fails, so it is worth recognising by sight. Type the variable the prefix came from, usually a prop; injectForm has the row-component version.

When a path is rejected, the compiler names it:

attaform: 'boxes.0.nope' is not a path in this form's schema

and for register, which binds leaves rather than containers:

attaform: 'boxes.0' is not a registrable path. v-register binds a leaf
input, so container paths are excluded.

Where to next