Unions and discriminated unions
When data can be more than one shape
Up to now, every field in our schemas has been exactly one type. A name is a string. A rating is a number. An address is a specific object shape. But sometimes a field can legitimately be different things. A search query might accept a string or a number. A notification payload might be an email, an SMS, or a push notification, each with a completely different shape.
Zod handles this with unions.
z.union(): “this OR that”
z.union() accepts a value if it matches any of the provided schemas:
import { z } from "zod/v4";
const StringOrNumber = z.union([z.string(), z.number()]);
StringOrNumber.parse("hello"); // returns "hello"
StringOrNumber.parse(42); // returns 42
StringOrNumber.parse(true); // fails: "Expected string | number" Zod tries each schema in order. If the first one matches, it returns the result. If not, it tries the next. If none match, it reports an error.
For simple types like string | number, this works great. But what about unions of objects?
Union of objects
Imagine a payment API that accepts credit cards and bank transfers. Each payment method has a different shape:
const CreditCard = z.object({
method: z.literal("credit_card"),
cardNumber: z.string().length(16),
expiry: z.string(),
});
const BankTransfer = z.object({
method: z.literal("bank_transfer"),
accountNumber: z.string(),
routingNumber: z.string(),
});
const PaymentSchema = z.union([CreditCard, BankTransfer]); PaymentSchema.parse({
method: "credit_card",
cardNumber: "1234567890123456",
expiry: "12/25",
}); // passes: matches CreditCard
PaymentSchema.parse({
method: "bank_transfer",
accountNumber: "123456789",
routingNumber: "021000021",
}); // passes: matches BankTransfer Notice the z.literal() on the method field. Each object has a field that identifies which shape it is. This works, but there is a problem.
The error message problem
What happens when a z.union() of objects fails?
PaymentSchema.parse({ method: "paypal" });
// Error: "Invalid input" That error is not very helpful. What happened behind the scenes? Zod tried CreditCard (failed because method is not "credit_card"), then tried BankTransfer (failed because method is not "bank_transfer"). Both failed, for different reasons. The combined error message is vague because Zod does not know which schema you intended.
For a form where the user chose “PayPal” from a dropdown, you want the error to say “Invalid payment method” or something specific. Not a confusing “Invalid input” that does not point anywhere useful.
z.discriminatedUnion(): the better way
z.discriminatedUnion() solves this. You tell Zod which field determines the shape (called the discriminator), and Zod uses it to pick the right schema before validating:
const PaymentSchema = z.discriminatedUnion("method", [
z.object({
method: z.literal("credit_card"),
cardNumber: z.string().length(16),
expiry: z.string(),
}),
z.object({
method: z.literal("bank_transfer"),
accountNumber: z.string(),
routingNumber: z.string(),
}),
]); The first argument, "method", is the discriminator field. Zod looks at the method value first. If it is "credit_card", Zod validates against the credit card schema only. If it is "bank_transfer", the bank transfer schema only. If it is neither, it fails with a clear error about the invalid discriminator value.
Now look at what happens when validation fails on a specific field:
PaymentSchema.parse({
method: "credit_card",
cardNumber: "123", // too short
});
// Error on cardNumber: "String must contain exactly 16 character(s)" Zod knew to use the CreditCard schema because method is "credit_card". The error is specific to that schema: the card number is too short. No ambiguity.
When to use which
z.union() works well for simple types like string | number, or when there is no natural field to use as a discriminator.
z.discriminatedUnion() is what you want for object unions where a specific field determines the shape. Better error messages, better performance (Zod checks one schema instead of trying all of them), and clearer intent.
If your objects have a “type” or “kind” or “method” field, reach for z.discriminatedUnion().
Now we have covered everything about the shape of data: primitives, objects, nested objects, arrays, and unions. In the next section, we will learn how to transform data during validation and how to write custom validation rules that go beyond what the built-in methods can do.
Exercises
Exercise 1: Create a z.union([z.string(), z.number(), z.boolean()]). Test with each type and with null.
Exercise 2: Create a discriminated union for notification types: email (to, subject, body), SMS (phone, message), push (deviceId, title, body). Use “type” as the discriminator.
Exercise 3: Parse an invalid discriminated union (wrong discriminator value). Compare the error message to a regular z.union() error.
Why is z.discriminatedUnion() better than z.union() for object schemas?