The exact problem: a form has a delivery method with two branches, and the schema expresses it as an object where every branch’s fields are optional plus a refinement that makes some of them required — so the type says a collection order might have a post code, and nothing stops code from reading one.
Context and Prerequisites
This is the structural alternative to the conditional rules in conditional required fields without cycles, built on the Zod foundations in integrating Zod for schema validation. The difference it makes is not stylistic: with a union, the impossible combination stops compiling.
Core Pattern
import { z } from 'zod';
// The discriminator must be a literal in every branch. Zod uses it to pick a
// branch WITHOUT trying the others, which is why errors stay specific.
const deliverySchema = z.discriminatedUnion('method', [
z.object({
method: z.literal('home'),
line1: z.string().min(1, 'Enter the first line of the address'),
postcode: z.string().min(4, 'Enter a post code, for example M1 4AB'),
}),
z.object({
method: z.literal('collect'),
pickupPointId: z.string().min(1, 'Choose a collection point'),
// No address fields exist here at all — not optional, absent.
}),
]);
type Delivery = z.infer<typeof deliverySchema>;
// The payoff is at the type level: this does not compile, because postcode is
// not a property of the collect branch.
function label(d: Delivery): string {
if (d.method === 'collect') return d.pickupPointId; // narrowed
return d.postcode; // narrowed the other way
}
Compare that with the optional-plus-refinement shape, where postcode is string | undefined in every branch, every consumer needs a non-null assertion, and the refinement is the only thing preventing a collection order from carrying an address.
Step-by-Step Walkthrough
-
Find the discriminator. It is the field the reader picks first — a radio group, a select. If there is no such field, a union is the wrong shape.
-
Give it a literal type in every branch.
z.literal('home'), notz.string(). -
Put each branch’s fields only in that branch. Not optional in a shared object.
-
Render from the branch. The form’s field list for the current branch comes from the same union, so a new branch cannot be added without its fields appearing.
-
Handle the not-yet-chosen state. Before the reader picks, the value matches no branch. Either default the discriminator or make the wrapper optional and treat “unchosen” as its own state.
-
Compose with the whole form. The union is one property of the form object; the rest of the schema is unaffected.
Failure Modes and Edge Cases
1. The discriminator is not set yet
safeParse reports that the discriminator is invalid, which is the right message — but only if you render it against the chooser. Attach it to the radio group, not to a field inside a branch that does not exist yet.
2. Shared fields duplicated across branches
A field present in every branch is noise repeated per branch. Intersect a shared object with the union rather than copying it.
3. Values kept from the other branch
Switching from home to collect leaves the address values in state, and the union no longer parses them — usually harmlessly, since they are dropped, but they will reappear if the reader switches back, which is often what you want. Decide deliberately rather than discovering it.
4. Three or more branches
Unions scale fine; the form does not, if every branch renders a different field set with no shared layout. Keep the branch-specific part small.
5. The server does not model it as a union
If the API accepts a flat object with optional fields, the union has to be flattened on the way out. Do it in one adapter, and keep the union as the client’s model.
Verification Checklist
Common Pitfalls
- A union without a discriminator. Every branch is attempted, so a failure reports every branch’s errors at once and the reader sees a wall of messages about fields they never chose.
- Optional fields plus a refinement. The type narrows nothing, every consumer needs an assertion, and only a runtime rule prevents an impossible record.
- Duplicating shared fields per branch. The same field repeated in three branches is three places to change it. Intersect a shared object with the union instead.
- Leaving the unchosen state undefined. A value matching no branch produces required errors for every branch’s fields. Default the discriminator, or treat unchosen as its own state.
- Rendering a hand-written field list. Derive the rendered fields from the same union that validates them, or a new branch ships without its fields appearing.
Related
- Integrating Zod for Schema Validation — the foundations
- Conditional Required Fields Without Cycles — the rule-based alternative
- Validating Only the Current Step — where a conditional step meets a union
← Integrating Zod for Schema Validation
Frequently Asked Questions
When is a discriminated union the wrong shape?
When there is no field the reader picks that determines the rest. A threshold rule — ‘a reason is required when the amount is over 500’ — has no discriminator, only a predicate, so it is a refinement. A union also gets unwieldy past three or four branches if each renders a completely different field set, at which point separate forms are often clearer than one form with four faces.
How do I handle the state before the reader has chosen?
Either default the discriminator to the most common branch, which makes the value always parseable, or wrap the union in an optional and treat ‘unchosen’ as a distinct state your rendering understands. The one thing to avoid is letting an unchosen value fall through to a parse that reports required errors for every branch’s fields — the reader sees six messages about fields they have not been shown.
Do refinements still work on a discriminated union?
Yes, and they are the right place for rules that span branches or that involve fields outside the union. Attach the refinement to the object containing the union rather than to a branch, so it can see everything, and set the path explicitly to reach a field inside the selected branch. A refinement on a single branch is fine too; it simply never runs when another branch is selected.