A union type allows a value to be one of several allowed types. Use the | symbol to join alternatives. Union types are useful when real data can arrive in more than one valid form.
Unions are common with route parameters, form inputs, API responses, UI states, feature flags, and values that start simple but become more specific after validation.
A union does not mean you can use every method from every member. Before using a member-specific method, narrow the value to the correct branch.
function printId(id: string | number): void {
if (typeof id === "string") {
console.log(`String ID: ${id.toUpperCase()}`);
return;
}
console.log(`Numeric ID: ${id.toFixed(0)}`);
}
printId(101);
printId("USR-101");
When a value is string | number, TypeScript only lets you use behavior that is safe for both strings and numbers. This prevents calling toUpperCase() on a number or toFixed() on a string.
Narrowing is the process of proving which union member you currently have. Common narrowing tools include typeof, equality checks, in, discriminated unions, and custom type guards.
| Check | Best For |
|---|---|
typeof value === "string" | Primitive unions |
status === "success" | Literal unions |
"data" in response | Object unions with different properties |
isUser(value) | Reusable runtime validation |
Literal types restrict a value to exact strings, numbers, or booleans. Instead of saying a theme is any string, you can say it must be "light", "dark", or "system".
This is more precise than a plain string and catches invalid options at compile time. Literal unions are excellent for statuses, roles, modes, directions, sizes, variants, and command names.
type Theme = "light" | "dark" | "system";
type ButtonSize = "sm" | "md" | "lg";
function setTheme(theme: Theme): void {
console.log(`Theme changed to ${theme}`);
}
function buttonClass(size: ButtonSize): string {
return `btn btn-${size}`;
}
setTheme("dark");
// setTheme("blue"); // Error
A value that may be missing should say so in its type. With strict null checks enabled, User | null forces you to handle the missing case before using the user.
This is better than pretending a value always exists. It makes loading states, optional selections, and lookup failures visible in the code.
type User = {
id: number;
name: string;
};
function getDisplayName(user: User | null): string {
if (user === null) {
return "Guest";
}
return user.name;
}
Object unions are useful when each valid shape has different properties. TypeScript will only allow access to properties that are safe for the current narrowed branch.
You can narrow object unions using the in operator, a shared literal property, or a custom type guard.
type EmailContact = {
email: string;
};
type PhoneContact = {
phone: string;
};
function contactLabel(contact: EmailContact | PhoneContact): string {
if ("email" in contact) {
return `Email: ${contact.email}`;
}
return `Phone: ${contact.phone}`;
}
A discriminated union is an object union where every branch has a shared literal property. That shared property is called the discriminator. It tells TypeScript which object shape is active.
This pattern is very useful for UI loading states, API responses, payment flows, form submission states, and any workflow where each state has different data.
| State | Available Data |
|---|---|
idle | No extra data needed. |
loading | No extra data yet. |
success | The loaded data is available. |
error | An error message is available. |
type LoadState =
| { status: "idle" }
| { status: "loading" }
| { status: "success"; data: string[] }
| { status: "error"; message: string };
function render(state: LoadState): string {
switch (state.status) {
case "success":
return state.data.join(", ");
case "error":
return state.message;
case "loading":
return "Loading...";
default:
return "Ready";
}
}
An exhaustive check helps ensure every union branch is handled. If you later add a new state and forget to update the switch, TypeScript can report the missing case.
This is one of the strongest reasons to use discriminated unions. Instead of discovering a missing UI state in production, you get a compiler warning while editing.
function assertNever(value: never): never {
throw new Error(`Unhandled value: ${JSON.stringify(value)}`);
}
function label(state: LoadState): string {
switch (state.status) {
case "idle":
return "Idle";
case "loading":
return "Loading";
case "success":
return "Loaded";
case "error":
return "Failed";
default:
return assertNever(state);
}
}
Union types can describe array values and object values too. For example, a list of roles can be typed as UserRole[], and a lookup table can use a union as its key type.
This keeps option lists, permissions, and configuration objects aligned with the exact values the application supports.
type UserRole = "admin" | "editor" | "viewer";
const roles: UserRole[] = ["admin", "editor", "viewer"];
const labels: Record<UserRole, string> = {
admin: "Administrator",
editor: "Content Editor",
viewer: "Read Only User",
};
function labelRole(role: UserRole): string {
return labels[role];
}
A good union should model real alternatives. Avoid unions that are so broad they stop being useful, such as string | number | object | any. The more precise the alternatives, the more TypeScript can help.
For object unions, prefer a clear discriminator such as type, kind, or status. This keeps narrowing simple and makes the data shape easier to read.
null or undefined in a union when missing values are valid.
Explore 500+ free tutorials across 20+ languages and frameworks.