4. Unions, Discriminated Unions & Intersections
Modern APIs frequently accept polymorphic payloads—such as webhook events with distinct shapes per event type, payment methods that require mutually exclusive credentials, or configuration fields with optional, nullable, and fallback defaults. Zod v4 provides a complete suite of algebraic type combinators (z.union, z.discriminatedUnion, z.xor, z.intersection) alongside fine-grained wrapper modifiers (.optional(), .nullable(), .default(), .prefault(), .catch(), .readonly(), and .brand()).
Source Code
from drawlib.canvas import save, setup
from drawlib.icons import phosphor
from drawlib.smartarts import CardList, Panel
from drawlib.styles import Styles
setup(width=144, height=64)
left_panel = Panel(
"z.union & z.xor",
"O(N) Sequential Trial",
style=Styles.Neutral,
header_style=Styles.SecondaryNeutral.patch(shape_line_width=1.2),
text_style=Styles.DarkBold.patch(text_size=11.0),
description_style=Styles.Dark.patch(text_size=10.0),
icon=phosphor.git_branch,
icon_style=Styles.DarkBold,
header_variant="filled",
header_height=9.5,
)
left_panel.draw((4.0, 4.0), width=66.0, height=56.0, align="bottom_left")
l_xy, l_w, l_h = left_panel.get_content_box(
(4.0, 4.0), width=66.0, height=56.0, padding=(3.0, 2.5), align="bottom_left"
)
seq_cards = CardList(
style=Styles.White,
text_style=Styles.DarkBold.patch(text_size=10.5),
description_style=Styles.Dark.patch(text_size=10.0),
badge_style=Styles.SecondaryNeutral,
badge_text_style=Styles.DarkBold.patch(text_size=10.0),
spacing=2.2,
show_arrows=True,
arrow_style=Styles.DarkBold,
)
seq_cards.add(
"1. Evaluate Options in Order",
"Tests Option A, then Option B, then Option C...",
badge_text="O(N)",
)
seq_cards.add(
"2. z.union([A, B, C])",
"Inclusive OR: returns first matching branch",
badge_text="OR",
)
seq_cards.add(
"3. z.xor([A, B]) (New in v4)",
"Exclusive OR: fails if 0 or >1 branches match",
badge_text="XOR",
)
seq_cards.draw(l_xy, width=l_w, height=l_h)
right_panel = Panel(
"z.discriminatedUnion",
"O(1) Hash Map Dispatch",
style=Styles.PrimaryNeutral,
header_style=Styles.PrimaryFlat,
text_style=Styles.WhiteBold.patch(text_size=11.0),
description_style=Styles.White.patch(text_size=10.0),
icon=phosphor.lightning,
icon_style=Styles.White,
header_variant="filled",
header_height=9.5,
)
right_panel.draw((74.0, 4.0), width=66.0, height=56.0, align="bottom_left")
r_xy, r_w, r_h = right_panel.get_content_box(
(74.0, 4.0), width=66.0, height=56.0, padding=(3.0, 2.5), align="bottom_left"
)
disc_cards = CardList(
style=Styles.White,
text_style=Styles.DarkBold.patch(text_size=10.5),
description_style=Styles.Dark.patch(text_size=10.0),
badge_style=Styles.PrimaryNeutral,
badge_text_style=Styles.PrimaryBold.patch(text_size=10.0),
spacing=2.2,
show_arrows=True,
arrow_style=Styles.PrimaryBold,
)
disc_cards.add(
"1. Read Discriminator Key",
"Extracts tag property (e.g. input['type'])",
badge_text="KEY",
)
disc_cards.add(
"2. O(1) Discriminator Lookup",
"Dispatches directly to matching object branch",
badge_text="O(1)",
style=Styles.PrimaryFlat,
text_style=Styles.WhiteBold.patch(text_size=10.5),
description_style=Styles.White.patch(text_size=10.0),
badge_style=Styles.White,
badge_text_style=Styles.PrimaryBold.patch(text_size=10.0),
)
disc_cards.add(
"3. Nested & Multi-Literal Tags",
"Supports z.enum, multi-literal & nested DUs",
badge_text="AST",
)
disc_cards.draw(r_xy, width=r_w, height=r_h)
save()
4.1 Standard Unions (z.union and .or())
z.union([A, B, ...]) (or the fluent .or() method) models an inclusive logical OR (A | B). At runtime, Zod tests each member schema sequentially in array order and returns the parsed output of the first schema that succeeds:
import { z } from "zod";
const StringOrNumber = z.union([z.string(), z.number()]);
// Equivalent fluent syntax:
const FluentStringOrNumber = z.string().or(z.number());
type StringOrNumber = z.infer<typeof StringOrNumber>; // string | number
StringOrNumber.parse("hello"); // => "hello"
StringOrNumber.parse(42); // => 42
Ordering Tip: Because
z.union()short-circuits on the first matching branch, place more specific schemas before broader ones (or before schemas with.transform()/.looseObject()) when branches overlap.
4.2 Discriminated Unions (z.discriminatedUnion)
When union members are objects that share a common literal or enum property (the discriminator, such as type, kind, or status), always prefer z.discriminatedUnion(discriminator, options) over z.union(options).
Instead of testing every branch sequentially in $O(N)$ time, z.discriminatedUnion() pre-indexes all branches by their discriminator values at schema construction time and performs an $O(1)$ map lookup during parsing. It also produces targeted error messages scoped specifically to the matched branch rather than a generic invalid_union error across all branches.
const PaymentEvent = z.discriminatedUnion("type", [
z.object({
type: z.literal("charge.succeeded"),
chargeId: z.uuid(),
amountCents: z.int().positive(),
}),
z.object({
type: z.literal("charge.failed"),
chargeId: z.uuid(),
reasonCode: z.enum(["insufficient_funds", "card_declined", "expired_card"]),
}),
z.object({
type: z.literal("refund.issued"),
refundId: z.uuid(),
chargeId: z.uuid(),
}),
]);
type PaymentEvent = z.infer<typeof PaymentEvent>;
Zod v4 Upgrades: Multi-Literal Tags & Nested Discriminated Unions
In Zod v4, z.discriminatedUnion() is significantly more capable than in v3:
- Multi-value discriminators: A single branch's discriminator property can be a multi-value
z.literal(["http", "https"])orz.enum(["created", "updated"]). - Composable / nested discriminated unions: You can pass an existing
z.discriminatedUnion()directly as an option inside anotherz.discriminatedUnion()that shares the same discriminator key!
const ReadEvent = z.discriminatedUnion("op", [
z.object({ op: z.literal("get"), key: z.string() }),
z.object({ op: z.literal("scan"), prefix: z.string() }),
]);
const WriteEvent = z.discriminatedUnion("op", [
z.object({ op: z.literal(["put", "upsert"]), key: z.string(), value: z.unknown() }),
z.object({ op: z.literal("delete"), key: z.string() }),
]);
// Compose two discriminated unions into a unified O(1) dispatcher
const StorageEvent = z.discriminatedUnion("op", [ReadEvent, WriteEvent]);
4.3 Exclusive Unions (z.xor)
Standard z.union() implements an inclusive OR: if an input satisfies two branches simultaneously, the first branch wins. When modeling mutually exclusive credentials or JSON Schema oneOf constraints where matching more than one branch is an error, use Zod v4's z.xor(options):
// Authenticate via API Key OR OAuth Bearer Token, never both simultaneously
const AuthConfig = z.xor([
z.object({ apiKey: z.string().min(32) }),
z.object({ bearerToken: z.jwt() }),
]);
// 1. Matches exactly one branch -> succeeds!
AuthConfig.parse({ apiKey: "zod_key_0123456789abcdef0123456789abcdef" });
// 2. Matches BOTH branches (because default z.object strips unknown keys) -> FAILS!
const ambiguous = AuthConfig.safeParse({
apiKey: "zod_key_0123456789abcdef0123456789abcdef",
bearerToken: "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.e30.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c",
});
// ambiguous.success === false (exclusive union violation)
4.4 Intersections (z.intersection and .and())
z.intersection(A, B) (or A.and(B)) models a logical AND (A & B), requiring the input to satisfy both schemas and merging their parsed outputs:
const Timestamped = z.object({
createdAt: z.iso.datetime(),
updatedAt: z.iso.datetime(),
});
const SoftDeletable = z.object({
deletedAt: z.iso.datetime().nullable(),
});
const AuditMetadata = z.intersection(Timestamped, SoftDeletable);
// Equivalent fluent syntax: Timestamped.and(SoftDeletable)
Best Practice: When combining two plain
ZodObjectschemas, preferTimestamped.extend(SoftDeletable.shape)overz.intersection()..extend()produces a flatZodObjectthat retains.pick(),.omit(),.partial(), and.keyof()methods, whereasz.intersection()returns aZodIntersectionwrapper. Reservez.intersection()for intersecting unions, records, or refined schemas.
4.5 Optional, Nullable, Default & Prefault Combinators
Source Code
from drawlib.canvas import save, setup
from drawlib.icons import phosphor
from drawlib.smartarts import CardList, Panel, Table
from drawlib.styles import Styles
setup(width=144, height=64)
left_panel = Panel(
".default() vs .prefault()",
"Fallback Semantics",
style=Styles.PrimaryNeutral,
header_style=Styles.PrimaryFlat,
text_style=Styles.WhiteBold.patch(text_size=11.0),
description_style=Styles.White.patch(text_size=10.0),
icon=phosphor.funnel,
icon_style=Styles.White,
header_variant="filled",
header_height=9.5,
)
left_panel.draw((4.0, 4.0), width=66.0, height=56.0, align="bottom_left")
l_xy, l_w, l_h = left_panel.get_content_box(
(4.0, 4.0), width=66.0, height=56.0, padding=(3.0, 2.5), align="bottom_left"
)
def_cards = CardList(
style=Styles.White,
text_style=Styles.DarkBold.patch(text_size=10.5),
description_style=Styles.Dark.patch(text_size=10.0),
badge_style=Styles.SecondaryNeutral,
badge_text_style=Styles.DarkBold.patch(text_size=10.0),
spacing=2.0,
)
def_cards.add(
".default(outputValue)",
"Short-circuits inner schema when undefined",
badge_text="OUT",
)
def_cards.add(
".prefault(inputValue) (New in v4)",
"Feeds inputValue into inner parse & transforms",
badge_text="IN",
style=Styles.PrimaryFlat,
text_style=Styles.WhiteBold.patch(text_size=10.5),
description_style=Styles.White.patch(text_size=10.0),
badge_style=Styles.White,
badge_text_style=Styles.PrimaryBold.patch(text_size=10.0),
)
def_cards.add(
".catch(fallbackOrFn)",
"Recovers from any validation error with fallback",
badge_text="ERR",
)
def_cards.draw(l_xy, width=l_w, height=l_h)
right_panel = Panel(
"Wrapper Combinators",
"Type & Runtime Modifiers",
style=Styles.Neutral,
header_style=Styles.SecondaryNeutral.patch(shape_line_width=1.2),
text_style=Styles.DarkBold.patch(text_size=11.0),
description_style=Styles.Dark.patch(text_size=10.0),
icon=phosphor.shield_check,
icon_style=Styles.DarkBold,
header_variant="filled",
header_height=9.5,
)
right_panel.draw((74.0, 4.0), width=66.0, height=56.0, align="bottom_left")
r_xy, r_w, r_h = right_panel.get_content_box(
(74.0, 4.0), width=66.0, height=56.0, padding=(3.0, 2.5), align="bottom_left"
)
wrap_table = Table(
cell_style=Styles.White,
text_style=Styles.Dark.patch(text_size=10.0),
header_cell_style=Styles.PrimaryFlat,
header_text_style=Styles.WhiteBold.patch(text_size=10.0),
border_style=Styles.MutedThin,
)
wrap_table.draw(
xy=r_xy,
width=r_w,
height=r_h,
align="bottom_left",
data=[
["Wrapper Method", "Inferred Output Type"],
[".optional()", "T | undefined"],
[".nullable()", "T | null"],
[".nullish()", "T | null | undefined"],
[".readonly()", "Readonly<T> (Object.freeze)"],
[".brand<'Tag'>()", "T & z.$brand<'Tag'>"],
],
)
save()
.optional(), .nullable(), .nullish(), and .nonoptional()
const BioSchema = z.string().optional(); // string | undefined
const PhoneSchema = z.string().nullable(); // string | null
const NickSchema = z.string().nullish(); // string | null | undefined
// Unwrap the inner schema at any time with `.unwrap()`:
BioSchema.unwrap(); // ZodString
// Reject `undefined` on a schema that previously allowed it:
const RequiredBio = BioSchema.nonoptional(); // string
.default() vs .prefault() (Critical Zod v4 Distinction)
In Zod v4, .default() and .prefault() solve two distinct default-value scenarios when the input is undefined:
.default(outputValue)— Short-Circuit Output Default: If the input isundefined, Zod short-circuits the inner schema and immediately returnsoutputValuewithout running inner checks or transforms. Therefore, the argument to.default()must match the schema's output type (z.output<T>)..prefault(inputValue)— Pre-Parse Input Default: If the input isundefined, Zod substitutesinputValueand feeds it through the entire inner parsing and transformation pipeline. Therefore, the argument to.prefault()matches the schema's input type (z.input<T>).
// A pipeline that trims a string and parses its length into a number
const TrimmedLength = z
.string()
.trim()
.transform((val) => val.length);
// Input type: string | undefined
// Output type: number
// 1. `.default()` expects the OUTPUT type (`number`) and short-circuits:
const WithDefault = TrimmedLength.default(0);
WithDefault.parse(undefined); // => 0 (transform is skipped!)
// 2. `.prefault()` expects the INPUT type (`string`) and runs the pipeline:
const WithPrefault = TrimmedLength.prefault(" hello ");
WithPrefault.parse(undefined); // => 5 (" hello " -> trimmed to "hello" -> length 5)
Dynamic Defaults: Both
.default(() => ...)and.prefault(() => ...)accept factory functions (e.g.,.default(() => new Date())or.default(() => crypto.randomUUID())) that are invoked freshly on eachundefinedinput.
Error Recovery with .catch()
Whereas .default() only triggers when the input is undefined, .catch(fallbackValueOrFn) catches any validation failure on the inner schema and returns a valid fallback output value. When given a callback (ctx) => ..., ctx.error and ctx.input are available for logging or inspection:
const SafePort = z.int().min(1).max(65535).catch(8080);
SafePort.parse(3000); // => 3000
SafePort.parse(99999); // => 8080 (recovered from out-of-range error)
SafePort.parse("invalid"); // => 8080 (recovered from invalid_type error)
// Inspecting validation issues inside a `.catch()` callback:
const TrackedRole = z.enum(["admin", "user"]).catch((ctx) => {
console.warn(`Invalid role "${String(ctx.input)}", falling back to "user"`, ctx.error.issues);
return "user";
});
4.6 Immutability & Nominal Branding (.readonly(), .brand())
Runtime & Static Immutability with .readonly()
Calling .readonly() on an object, array, tuple, Map, or Set schema both marks the inferred TypeScript output type as Readonly<T> (or ReadonlyArray<T> / ReadonlyMap / ReadonlySet) and calls Object.freeze() on the parsed value at runtime:
const FrozenConfig = z
.object({
host: z.string(),
port: z.int(),
})
.readonly();
type FrozenConfig = z.infer<typeof FrozenConfig>;
// Readonly<{ host: string; port: number }>
const cfg = FrozenConfig.parse({ host: "localhost", port: 5432 });
Object.isFrozen(cfg); // => true
Nominal Typing with .brand()
TypeScript's type system is structural: two string variables (userId and organizationId) are interchangeable even if they represent completely different domain identifiers. Attaching .brand<"Tag">() attaches a compile-time nominal brand symbol (z.$brand<"Tag">) with zero runtime overhead:
const UserId = z.uuid().brand<"UserId">();
const OrgId = z.uuid().brand<"OrgId">();
type UserId = z.infer<typeof UserId>; // string & z.$brand<"UserId">
type OrgId = z.infer<typeof OrgId>; // string & z.$brand<"OrgId">
function fetchUserProfile(id: UserId) {
return id;
}
const uid = UserId.parse("550e8400-e29b-41d4-a716-446655440000");
const oid = OrgId.parse("6ba7b810-9dad-11d1-80b4-00c04fd430c8");
fetchUserProfile(uid); // OK!
// fetchUserProfile(oid); // TypeScript Compile Error: OrgId is not assignable to UserId
// fetchUserProfile("550e8400-..."); // TypeScript Compile Error: raw string is not branded
Note: In Zod v4,
.brand<"Tag", "in" | "out" | "inout">()also accepts an optional second type parameter controlling whether the brand is applied toz.output<T>("out", the default),z.input<T>("in"), or both ("inout").
Next, proceed to 5. Refinements, Pipelines & Bidirectional Codecs to master custom validation rules (.refine(), .superRefine(), .check()), in-place mutations (.overwrite()), and Zod v4's flagship bidirectional z.codec() API.