How to Use Zod Union and Enum Types: A Complete Guide
Zod union and enum types are first-class schema objects that validate values against multiple possible shapes or specific literal values, with full TypeScript type inference and runtime validation.
The colinhacks/zod library provides powerful primitives for modeling polymorphic data and fixed sets of constants. Understanding how to leverage z.union() and z.enum() allows you to build type-safe schemas that accurately represent complex domain constraints while maintaining excellent developer experience through autocomplete and compile-time checking.
Understanding Zod Union Types
Union types in Zod represent values that can match one of several possible schemas. Internally, these are implemented as ZodUnion<T> classes that extend the base ZodType and maintain a runtime definition object while preserving exact compile-time types.
How Union Schemas Work Internally
According to the Zod source code, the union implementation spans both the core and classic API layers. In packages/zod/src/v4/core/schemas.ts (lines 2063-2121), the $ZodUnion class defines the runtime behavior, including the handleUnionResults helper that merges error information from all branches when no option matches. The classic API exposes this through ZodUnion<T> in packages/zod/src/v4/classic/schemas.ts (lines 1327-1344), providing the z.union([...]) factory function.
When parsing, Zod iterates through the option schemas and short-circuits on the first successful parse. If all branches fail, the error messages are aggregated to provide a helpful diagnostic.
Creating Simple Unions with z.union()
The z.union() helper accepts an array of schemas and returns a union type that validates against any one of them:
import { z } from "zod";
// Union of string or number
const StringOrNumber = z.union([z.string(), z.number()]);
// Both parse successfully
StringOrNumber.parse("hello"); // "hello"
StringOrNumber.parse(42); // 42
// Boolean fails with aggregated error
try {
StringOrNumber.parse(true);
} catch (e) {
console.error(e.errors); // Shows failures from both branches
}
Optimizing with Discriminated Unions
For object unions with a shared discriminator property, use z.discriminatedUnion(). This builds on the same $ZodUnion internals but adds a fast lookup table for the discriminator key, improving performance and error specificity:
import { z } from "zod";
const Circle = z.object({
kind: z.literal("circle"),
radius: z.number(),
});
const Square = z.object({
kind: z.literal("square"),
side: z.number(),
});
const Shape = z.discriminatedUnion("kind", [Circle, Square]);
// Validates only the matching branch
Shape.parse({ kind: "circle", radius: 10 });
// Provides specific error for the selected branch
try {
Shape.parse({ kind: "square", radius: 5 }); // Wrong property for square
} catch (e) {
console.error(e.errors); // Indicates "side" is required
}
Working with Zod Enum Types
Zod provides two primary ways to model enumerated values: string literal enums for ad-hoc constants, and native enum support for existing TypeScript enums.
String Literal Enums with z.enum()
The z.enum() function creates a schema that validates against a fixed set of string literals. Internally, this constructs a ZodEnum class (defined in packages/zod/src/v4/classic/schemas.ts lines 1617-1682) backed by the core $ZodEnum implementation (lines 3051-3079 in packages/zod/src/v4/core/schemas.ts):
import { z } from "zod";
const Color = z.enum(["red", "green", "blue"]);
// Type is inferred as "red" | "green" | "blue"
type Color = z.infer<typeof Color>;
Color.parse("red"); // succeeds
Color.parse("yellow"); // throws ZodError
Native TypeScript Enums with z.nativeEnum()
For existing TypeScript enums, use z.nativeEnum() to lift the native enum into a Zod schema while preserving both runtime values and compile-time types:
import { z } from "zod";
enum Direction {
Up = "UP",
Down = "DOWN",
Left = "LEFT",
Right = "RIGHT",
}
const DirectionSchema = z.nativeEnum(Direction);
DirectionSchema.parse("UP"); // succeeds
DirectionSchema.parse("FORWARD"); // throws ZodError
This helper reads the enum's entries and constructs the appropriate validation logic in the core layer, ensuring that numeric enums, string enums, and mixed enums are all handled correctly.
Extracting Keys with z.keyof()
The z.keyof() utility demonstrates how enums serve as building blocks for other schemas. It extracts the keys of a ZodObject and returns a ZodEnum:
import { z } from "zod";
const Person = z.object({
name: z.string(),
age: z.number(),
});
const PersonKeys = z.keyof(Person);
// Equivalent to z.enum(["name", "age"])
type Keys = z.infer<typeof PersonKeys>; // "name" | "age"
This implementation reads Object.keys from the object's output type and feeds them into the enum constructor, showcasing the composability of Zod's type system.
Practical Code Examples
Example 1: API Response Union
Model an API that returns either a success object or an error object:
import { z } from "zod";
const SuccessResponse = z.object({
status: z.literal("success"),
data: z.object({ id: z.number() }),
});
const ErrorResponse = z.object({
status: z.literal("error"),
message: z.string(),
});
const ApiResponse = z.discriminatedUnion("status", [
SuccessResponse,
ErrorResponse,
]);
// Type narrowing works automatically
const result = ApiResponse.parse({ status: "success", data: { id: 1 } });
if (result.status === "success") {
console.log(result.data.id); // TypeScript knows data exists
}
Example 2: Configuration Enum with Native Enum
Validate configuration values against a TypeScript enum:
import { z } from "zod";
enum LogLevel {
DEBUG = 0,
INFO = 1,
WARN = 2,
ERROR = 3,
}
const ConfigSchema = z.object({
level: z.nativeEnum(LogLevel),
path: z.string(),
});
const config = ConfigSchema.parse({ level: LogLevel.INFO, path: "/var/log" });
// or
ConfigSchema.parse({ level: 1, path: "/var/log" }); // Also valid for numeric enums
Example 3: Heterogeneous Union with Type Guards
Handle unions of completely different primitives:
import { z } from "zod";
const Primitive = z.union([
z.string(),
z.number(),
z.boolean(),
z.null(),
]);
type Primitive = z.infer<typeof Primitive>; // string | number | boolean | null
function processValue(val: Primitive) {
if (typeof val === "string") {
return val.toUpperCase();
}
if (typeof val === "number") {
return val * 2;
}
return val;
}
Summary
- Zod union types allow values to match one of several schemas, implemented internally by the
ZodUnionclass inpackages/zod/src/v4/core/schemas.tsand exposed viaz.union(). - Discriminated unions optimize object unions with a shared key using
z.discriminatedUnion(), providing faster validation and clearer error messages through discriminator lookup tables. - String literal enums created with
z.enum()validate against fixed string constants, with theZodEnumclass defined inpackages/zod/src/v4/classic/schemas.ts(lines 1617-1682). - Native TypeScript enums integrate seamlessly using
z.nativeEnum(), preserving both runtime values and compile-time type safety for numeric, string, or mixed enums. - Both union and enum schemas propagate exact type information through generic parameters, enabling exhaustive switch statements and IDE autocompletion without manual type assertions.
Frequently Asked Questions
What is the difference between z.union() and z.discriminatedUnion() in Zod?
z.union() validates input against each schema option sequentially until one succeeds, making it ideal for heterogeneous types or primitives. z.discriminatedUnion() requires a discriminator key (like kind or type) and uses a lookup table to jump directly to the matching schema, offering better performance and more specific error messages for object unions. Both are implemented in packages/zod/src/v4/core/schemas.ts, but discriminated unions add a fast-path optimization for tagged objects.
How do I extract the TypeScript type from a Zod enum or union?
Use the z.infer<typeof schema> utility to extract the static TypeScript type. For enums created with z.enum(["a", "b"]), the inferred type is the union of string literals "a" | "b". For unions like z.union([z.string(), z.number()]), the inferred type is string | number. This works because both ZodUnion and ZodEnum propagate their generic type parameters through to the base ZodType, preserving exact type information at compile time.
Can I use numeric enums with z.nativeEnum()?
Yes, z.nativeEnum() fully supports numeric enums, string enums, and mixed enums. When you pass a TypeScript numeric enum like enum Level { Low = 1, High = 2 }, Zod validates that the input matches any of the enum's numeric values (1 or 2). The implementation in packages/zod/src/v4/core/schemas.ts reads the enum's entries to build the validation logic, ensuring both runtime safety and TypeScript type narrowing work correctly for numeric variants.
What happens when a Zod union validation fails?
When no branch of a union matches the input, Zod aggregates the error messages from all attempted branches into a single ZodError. The handleUnionResults helper in packages/zod/src/v4/core/schemas.ts (lines 2096-2121) merges the issues arrays from each failed option, providing a comprehensive error message that indicates why each branch failed. For discriminated unions, the error is more specific: if the discriminator value doesn't match any branch, or if the matched branch fails validation, the error points directly to the problematic property in the selected schema.
Have a question about this repo?
These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:
curl -s "https://instagit.com/install.md" Maintain an open-source project? Get it listed too →