TypeScript Union Types
Introduction
Union types are one of TypeScript's most powerful features that allows a variable to accept values of multiple types. Instead of restricting a variable to a single type like string
or number
, union types provide the flexibility to work with different types when needed, while still maintaining type safety.
In JavaScript, variables are dynamically typed, allowing any value to be assigned to any variable. TypeScript introduces static typing, but sometimes we need the flexibility to work with different types for a single variable. This is where union types come into play.
Understanding Union Types
A union type is denoted by the pipe (|
) symbol between types. It indicates that a variable can be any one of the specified types.
let value: string | number;
// Both are valid assignments
value = "Hello";
value = 42;
// Error: Type 'boolean' is not assignable to type 'string | number'
value = true;
In this example, value
can be either a string or a number, but not a boolean or any other type.
Working with Union Types
When you have a union type, you can only access properties and methods that are common to all types in the union.
function printLength(value: string | number) {
// Error: Property 'length' does not exist on type 'number'
console.log(value.length);
}
This code would result in a type error because while string
has a length
property, number
doesn't.
Type Narrowing
To access type-specific properties, you need to narrow down the type first using a technique called "type narrowing" or "type guards":
function printLength(value: string | number) {
if (typeof value === "string") {
// Inside this block, TypeScript knows value is a string
console.log(value.length); // Works fine
} else {
// Here, TypeScript knows value is a number
console.log(value.toFixed(2)); // Works fine
}
}
The typeof
check is one form of type guard that helps TypeScript understand which type it's dealing with in each code branch.
Practical Examples of Union Types
Optional Parameters with undefined
function greet(name: string | undefined) {
if (name === undefined) {
return "Hello, guest!";
}
return `Hello, ${name}!`;
}
console.log(greet("Alice")); // Output: Hello, Alice!
console.log(greet(undefined)); // Output: Hello, guest!
Function That Handles Different Types
function displayId(id: string | number) {
if (typeof id === "string") {
console.log(`ID: ${id.toUpperCase()}`);
} else {
console.log(`ID: ${id.toFixed(0)}`);
}
}
displayId("a-123"); // Output: ID: A-123
displayId(42); // Output: ID: 42
Error Handling with Union Types
type Result<T> =
| { success: true; value: T }
| { success: false; error: string };
function divide(a: number, b: number): Result<number> {
if (b === 0) {
return { success: false, error: "Cannot divide by zero" };
}
return { success: true, value: a / b };
}
const result = divide(10, 2);
if (result.success) {
console.log(`Result: ${result.value}`); // Output: Result: 5
} else {
console.log(`Error: ${result.error}`);
}
const errorResult = divide(10, 0);
if (errorResult.success) {
console.log(`Result: ${errorResult.value}`);
} else {
console.log(`Error: ${errorResult.error}`); // Output: Error: Cannot divide by zero
}
Union Types with Arrays
You can use union types with arrays in two different ways:
- Array of union types:
(string | number)[]
- an array where each element can be either a string or a number - Union of array types:
string[] | number[]
- either an array of strings or an array of numbers
// Array of union types
let mixedArray: (string | number)[] = ["hello", 42, "world", 100];
// Union of array types
let arrayOfOneType: string[] | number[];
arrayOfOneType = ["hello", "world"]; // Valid
arrayOfOneType = [1, 2, 3]; // Valid
arrayOfOneType = ["hello", 1]; // Error: Type 'number' is not assignable to type 'string'.
Literal Types with Unions
You can combine union types with literal types to create a set of allowed values:
type Direction = "North" | "South" | "East" | "West";
function move(direction: Direction) {
console.log(`Moving ${direction}...`);
}
move("North"); // Works
move("South"); // Works
move("Up"); // Error: Argument of type '"Up"' is not assignable to parameter of type 'Direction'.
This pattern is extremely useful for creating type-safe enums or restricting values to specific options.
Union Types with Objects
When working with objects, union types can represent different shapes of data:
type Rectangle = {
kind: "rectangle";
width: number;
height: number;
};
type Circle = {
kind: "circle";
radius: number;
};
type Shape = Rectangle | Circle;
function calculateArea(shape: Shape): number {
if (shape.kind === "rectangle") {
return shape.width * shape.height;
} else {
return Math.PI * shape.radius * shape.radius;
}
}
const rectangle: Shape = { kind: "rectangle", width: 5, height: 10 };
const circle: Shape = { kind: "circle", radius: 7 };
console.log(calculateArea(rectangle)); // Output: 50
console.log(calculateArea(circle)); // Output: 153.93804002589985
In this example, the kind
property acts as a discriminant, helping TypeScript know which object type you're working with.
Best Practices for Union Types
- Use type guards: Always narrow union types when accessing type-specific properties.
- Add discriminant properties: When using unions of object types, include a common property with different literal values to easily distinguish between them.
- Don't overuse: While union types are powerful, too many options can make code harder to understand and maintain.
- Consider using generics: For advanced scenarios, generic types might provide a cleaner solution than complex union types.
When to Use Union Types
Union types are particularly useful when:
- A function can handle multiple types of input
- A value could be one of several distinct types
- You want to model success and error states
- You need to represent a finite set of possible values (like an enum)
- You're working with optional values (using
T | undefined
orT | null
)
Summary
Union types allow TypeScript to express flexible yet type-safe code by enabling variables to accept multiple types. By using type guards to narrow the types within conditional blocks, you can safely access type-specific properties and methods.
Key points to remember:
- Use the pipe (
|
) operator to create union types - Narrow types with type guards before accessing type-specific properties
- Union types work well with literal types to create a set of allowed values
- For union types with objects, use discriminant properties to help TypeScript identify the correct type
By mastering union types, you can write more flexible code while maintaining strong type checking that catches potential errors at compile time rather than runtime.
Exercises
- Create a function that accepts either a string or an array of strings and returns the total number of characters.
- Define a
UserID
union type that can be either a number or a string in the format"user-{number}"
. - Create a
Result<T>
type that represents either a success with a value or an error with a message. - Implement a
parse
function that attempts to convert a string to a number and returns a union type indicating success or failure. - Create a
NetworkRequest
type that can represent the different states of a network request: "loading", "success" with data, or "error" with an error message.
Additional Resources
If you spot any mistakes on this website, please let me know at [email protected]. I’d greatly appreciate your feedback! :)