Union Types: Multiple Possibilities in TypeScript


What Are Union Types
A function that formats a user ID should accept both strings and numbers — sometimes IDs are numeric, sometimes they're UUIDs. In plain JavaScript you'd just accept anything and hope for the best. TypeScript gives you a better option: a union type that says "this value is either a string or a number, nothing else."
Union types model the reality that many values in software aren't one fixed thing. An API response might succeed or fail. A configuration value might be a string or an array of strings. A payment method might be a credit card, a bank transfer, or a PayPal account. Union types let you express all of these possibilities precisely, while TypeScript ensures you handle each case correctly.
Union Types Express OR Logic
A union type A | B means a value can be either type A or type B.
TypeScript tracks which type is active at any given point in your code, and
will only allow operations that are valid for all members of the union — or
for the specific member you've narrowed to.
The Pipe Syntax
Union types are written with the | (pipe) operator between two or more types:
let id: string | number;
id = "abc-123"; // valid
id = 42; // valid
id = true; // Error: type 'boolean' is not assignable to type 'string | number'You can union any types together — primitives, object types, literal types, even other unions:
type StringOrNumber = string | number;
type NullableString = string | null;
type ThreeWay = string | number | boolean;
// Object types in a union
type Cat = { kind: "cat"; meows: boolean };
type Dog = { kind: "dog"; barks: boolean };
type Pet = Cat | Dog;Unions work anywhere a type annotation is valid: variable declarations, function parameters, return types, and within interfaces or type aliases.
Accessing Common Properties
When you have a union type, TypeScript only lets you access properties that exist on every member of the union. If a property only exists on some members, TypeScript blocks access until you narrow the type.
type Circle = {
kind: "circle";
radius: number;
};
type Rectangle = {
kind: "rectangle";
width: number;
height: number;
};
type Shape = Circle | Rectangle;
function describe(shape: Shape) {
console.log(shape.kind); // OK — both Circle and Rectangle have 'kind'
console.log(shape.radius); // Error: property 'radius' does not exist on type 'Rectangle'
}kind is safe to access because it exists on both Circle and Rectangle. radius is blocked because Rectangle doesn't have it — TypeScript can't guarantee it's safe without knowing which shape you're working with.
Design Unions Around Shared Properties
Adding a shared discriminant property like kind or type to every union
member makes narrowing straightforward. You'll see this pattern frequently in
production codebases and it's covered in depth when we get to discriminated
unions.
Narrowing Union Types
Narrowing is the process of using runtime checks to tell TypeScript which member of a union you're dealing with. Once narrowed, TypeScript unlocks access to properties and methods specific to that type.
Narrowing with typeof
The typeof operator narrows primitive unions:
function formatId(id: string | number): string {
if (typeof id === "string") {
// TypeScript knows id is string here
return id.toUpperCase();
}
// TypeScript knows id is number here
return id.toFixed(0);
}
console.log(formatId("abc-123")); // "ABC-123"
console.log(formatId(42)); // "42"TypeScript understands typeof checks and uses them to narrow the type within each branch of the if statement.
Narrowing with instanceof
instanceof narrows class-based types:
class ApiError {
constructor(
public message: string,
public statusCode: number
) {}
}
class NetworkError {
constructor(
public message: string,
public isTimeout: boolean
) {}
}
type AppError = ApiError | NetworkError;
function handleError(error: AppError): void {
if (error instanceof ApiError) {
console.log(`API ${error.statusCode}: ${error.message}`);
} else {
// TypeScript knows error is NetworkError here
console.log(
`Network error (timeout: ${error.isTimeout}): ${error.message}`
);
}
}Narrowing with in
The in operator checks whether a property exists on an object — useful for narrowing plain object unions where typeof won't help:
type Cat = { meows: boolean };
type Dog = { barks: boolean };
type Pet = Cat | Dog;
function makeNoise(pet: Pet): void {
if ("meows" in pet) {
// TypeScript knows pet is Cat here
console.log(pet.meows ? "Meow!" : "...");
} else {
// TypeScript knows pet is Dog here
console.log(pet.barks ? "Woof!" : "...");
}
}Narrowing Must Be Exhaustive
If you don't handle every member of a union, TypeScript may still report
errors when accessing member-specific properties. Use else branches or
explicit checks to cover all cases. The never type (covered in a later post)
helps ensure you haven't missed any.
Real-World Patterns
Union types are especially useful for modeling API responses that can succeed or fail:
type SuccessResponse = {
status: "success";
data: User[];
total: number;
};
type ErrorResponse = {
status: "error";
message: string;
code: number;
};
type ApiResponse = SuccessResponse | ErrorResponse;
async function fetchUsers(): Promise<ApiResponse> {
try {
const response = await fetch("/api/users");
const data = await response.json();
return { status: "success", data: data.users, total: data.total };
} catch (err) {
return { status: "error", message: "Failed to fetch users", code: 500 };
}
}
function renderUsers(response: ApiResponse): void {
if (response.status === "success") {
console.log(`Showing ${response.total} users`);
response.data.forEach((user) => console.log(user.name));
} else {
console.error(`Error ${response.code}: ${response.message}`);
}
}Narrowing on status is enough — TypeScript knows which branch has data and which has message. This pattern of using a literal string property to discriminate a union is called a discriminated union, and it's one of the most powerful patterns in TypeScript's type system.
Best Practices
Use union types when:
- A parameter genuinely accepts multiple types (
string | number) - A value can be absent (
string | null,User | undefined) - A response can have different shapes depending on outcome (
SuccessResponse | ErrorResponse)
Avoid unions when:
- You're tempted to use
any— a union is almost always more precise - The union has too many unrelated members (consider a base type or interface instead)
// Good — a config value that can be a string or array of strings
type Origins = string | string[];
// Avoid — overly broad union that sidesteps type safety
type Anything = string | number | boolean | object | null | undefined;
// Use unknown instead if you truly don't know the typeWhen a function can return a value or null, consider whether null is truly
meaningful in your domain. Often, returning an empty array or a default object
is cleaner than forcing callers to handle null — but when absence is
meaningful, T | null is exactly right.
Key Takeaways
- Union types use the
|syntax to express that a value can be one of several types - TypeScript only allows access to properties shared by all members of a union until you narrow it
- Narrow unions with
typeof(primitives),instanceof(classes), orin(object properties) - Adding a shared literal property like
kindorstatusto union members makes narrowing clean and exhaustive - Union types are the foundation of discriminated unions — the most powerful pattern in TypeScript's advanced type system
Phase 4 Begins
You've taken your first step into TypeScript's advanced type system. Union types let you model real-world flexibility while keeping full type safety. Next up: intersection types — the AND to union's OR.