Choosing a Shape: Branches, Keys, and Cycles
Zod’s composition operators answer different questions about a value: is it one of several alternatives, exactly one alternative, a combination of constraints, a dictionary of keyed values, or a structure that refers back to itself? The focus here is choosing among those relationships rather than collecting constructors. Once the relationship is clear, the reader can predict how Zod selects a branch, merges outputs, validates keys, or preserves a recursive graph.
Alternatives are not all the same kind of “or”
Suppose an API returns either a successful result or a failure:
const Result = z.union([
z.object({ status: z.literal("success"), data: z.string() }),
z.object({ status: z.literal("failed"), error: z.string() }),
]);
A regular union means “one of these schemas can parse the value.” Zod tries the options in order and returns the first one that succeeds. That is useful when the alternatives are naturally ordered or when their broad shapes are enough to identify a valid result. It also means branch order is part of runtime behavior. Two options that overlap do not have equal opportunity: the earlier successful option wins.
For a small union of clearly different primitive types, this is easy to reason about. A string is accepted by the string branch and a number by the number branch. Object branches can be less obvious because a plain object schema strips unknown keys. Consider a base object with name and a second object with name plus version. An input containing both fields may satisfy both branches: the first branch strips version, while the second keeps it. The union is not expressing “the input has exactly one of these shapes” unless the branches are made mutually exclusive.
A discriminated union makes the selection rule explicit:
const Result = z.discriminatedUnion("status", [
z.object({ status: z.literal("success"), data: z.string() }),
z.object({ status: z.literal("failed"), error: z.string() }),
]);
Here the status property identifies the candidate branch. Zod can use that value rather than trying every option in sequence, and a failed parse can focus on the selected alternative. The design is especially natural for events, payment methods, or API responses whose variants already have a stable tag. Zod 4 also supports richer discriminator values and nested discriminated unions, so a broad status can lead to a second tag without flattening the model into one large list.
The discriminator is a routing key, not proof that the rest of the branch is valid. { status: "success", data: 42 } still has to pass the selected branch’s data schema. A missing or unknown tag is a selection failure. A known tag with a bad field is a validation failure inside the selected object. Keeping those cases distinct makes the schema’s failure evidence more useful.
The forward-selection story has a directional boundary. When a discriminator is built from codec-based values, the representation used for backward encoding may differ from the representation used for forward parsing, so encoding can need union matching rather than the same fast lookup. The general principle is broader than this one operator: a selection optimization is safe only when the discriminator means the same thing in the direction in which it is used.
z.xor() expresses a third relationship: exactly one option must match. It rejects an input when no option matches, as a normal union does, but it also rejects an input when multiple options match. This is appropriate for a payment request that must be either a card payment or a bank transfer, never both. It is not a faster spelling of z.union(); it asks Zod to count matches.
That count exposes why object strictness matters.