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.

Two ways to model a branch, and what each costs later With a discriminated union, the inferred type narrows on the discriminator so each branch exposes only its own fields, an impossible combination cannot be constructed, errors are attached to the fields of the selected branch only, and a missing discriminator produces one clear message about the discriminator itself. With an all-optional object plus a refinement, every branch-specific field is possibly undefined so consumers need assertions, an impossible combination is representable and only a runtime rule prevents it, errors come from the refinement and must set their own paths, and a missing discriminator produces required-field errors for every branch at once. Question discriminated union optional + refinement what the type says narrows per branch everything possibly undefined impossible combination unrepresentable representable; a rule forbids it where errors attach the selected branch only wherever the refinement says no discriminator yet one message, about it required errors for every branch The last row is what readers notice: choosing nothing should ask them to choose, not list six fields they have never seen. Three ways to express a branch, ranked A discriminated union is strongest: the type narrows, an impossible combination cannot be constructed, and choosing nothing produces one message about the chooser. A plain union without a discriminator still narrows but Zod must try every branch, so a failure reports every branch's errors and the reader sees a wall. An all-optional object with a refinement narrows nothing, so every consumer needs assertions and only a runtime rule prevents the impossible state. Expression Type narrows? Errors on failure discriminated union yes, on the tag one branch’s, or the tag’s plain union yes every branch’s, together optional + refinement no whatever the rule says The middle row is why the discriminator matters: without it every branch is attempted and every branch’s errors are reported.

Step-by-Step Walkthrough

  1. 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.

  2. Give it a literal type in every branch. z.literal('home'), not z.string().

  3. Put each branch’s fields only in that branch. Not optional in a shared object.

  4. 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.

  5. 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.

  6. 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.

What the form renders, per branch The chooser is always rendered — it is the discriminator, and it is what the reader picks. The selected branch's fields are rendered from the same union that validates them, so a branch cannot gain a field without the form showing it. The other branch's fields are not rendered, and their values are not submitted, because the union drops them. And the decision about whether to keep those values in state for a switch back is made once, deliberately. What the form renders, per branch the chooser always rendered — it is the discriminator this branch fields come from the same union the other branch not rendered, not submitted its old values kept or dropped — decide once Deriving the rendered field list from the union is what stops a new branch shipping without its fields appearing.

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

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.