Variant forms
When the form is one of several shapes, make the union the root. A
z.discriminatedUnionschema at the top level gives you a variant form:form.valuesreads the active variant, and writing the discriminator reshapes the whole form.
- Category
- Schema feature
- Form root
z.discriminatedUnion('key', [variantA, variantB, …])- Reshape trigger
- writing the discriminator
- Default
- first variant
The demo is a checkout payment method. A payment is exactly one of card, bank transfer, or invoice, each with its own fields, so the schema root is a z.discriminatedUnion('method', …). Switch the method and the form reshapes to that variant; the readout below tracks the change.
The schema
A variant form declares a z.discriminatedUnion schema as the root, not nested under a key. One discriminator (method) picks the variant; each variant is a regular Zod schema:
import { useForm } from 'attaform/zod'
import { z } from 'zod'
const schema = z.discriminatedUnion('method', [
z.object({ method: z.literal('card'), cardNumber: z.string(), cvc: z.string() }),
z.object({ method: z.literal('bank'), iban: z.string() }),
z.object({ method: z.literal('invoice'), poNumber: z.string(), netDays: z.number() }),
])
const form = useForm({ schema })
There is no wrapper key. The discriminator and every variant's fields sit at the top level of the form.
Reading the active variant
Because the union is the root, form.values reads the variant directly. Every variant's keys are reachable at the top level, so a field read works without narrowing first:
form.values.method // the discriminator: string while editing
form.values.cardNumber // string | undefined (present on the card variant)
form.values.iban // string | undefined (present on the bank variant)
A per-variant field reads as T | undefined because it is present only while its variant is active, matching the runtime, where reading a key from another variant returns undefined. The discriminator reads as string while editing, since the user may be mid-switch. Inside handleSubmit the parsed value is the true narrowed union, so you branch on it with full type narrowing:
const onSubmit = form.handleSubmit((payment) => {
if (payment.method === 'card') {
payment.cardNumber // string, narrowed to the card variant
}
})
Binding the discriminator and variant fields
The discriminator is an ordinary field: bind it to a <select> or a set of radios, and each variant's fields bind by their own key, no wrapper prefix. Branch the template on form.values.method:
<template>
<label>
Payment method
<select v-register="form.register('method')">
<option value="card">Card</option>
<option value="bank">Bank transfer</option>
<option value="invoice">Invoice</option>
</select>
</label>
<template v-if="form.values.method === 'card'">
<input v-register="form.register('cardNumber')" />
<em v-if="form.fields.cardNumber?.showErrors">{{
form.fields.cardNumber?.firstError?.message
}}</em>
</template>
<template v-else-if="form.values.method === 'bank'">
<input v-register="form.register('iban')" />
</template>
</template>
A variant field's node is absent while its variant is inactive, so reach it through ?.: form.fields.cardNumber?.showErrors. The same chaining applies to form.errors, which is variant-filtered, so an inactive variant's errors stay silent.
Switching variants reshapes the form
Writing the discriminator reshapes storage to the new variant: the outgoing variant's keys are purged, and the new variant's keys seed from the schema's slim defaults. At the root, the reshape is the whole form:
form.setValue('method', 'bank')
// form.values() : { method: 'bank', iban: '' }
// (card's cardNumber / cvc purged; bank's iban seeded)
This is the same engine that drives a nested discriminated union, run at the root path. Variant memory (restoring a variant's values when you switch back to it) is on by default. The full reshape and memory semantics live on Discriminated unions, which covers the same feature as a field inside an object form.
Defaults
A bare variant root defaults to the first variant, with its fields at their slim defaults:
const form = useForm({ schema })
form.values() // { method: 'card', cardNumber: '', cvc: '' }
Start on a different variant, or seed its fields, with defaultValues. Pass one complete variant:
const form = useForm({
schema,
defaultValues: { method: 'invoice', poNumber: 'PO-1', netDays: 30 },
})
When the shape is fixed, reach for an object
Variant forms are for a value that is genuinely one of several shapes. When every field is always present, a z.object root is the better fit. The two compose freely: an object form can hold a discriminated-union field (see Discriminated unions), and a variant's schema can be any Zod schema, including nested objects, arrays, and records.
Where to next
- Discriminated unions: the same feature as a field inside an object form, with the full reshape and error-filtering detail.
- Variant memory: when to keep a variant's values on switch-back, when to drop them.
- Dictionary forms: the other non-object root, a
z.recordmap. handleSubmit: where the parsed value narrows to the active variant.