Introduction to TypeScript Union Types
In the world of modern web development, TypeScript has emerged as a crucial tool for building scalable and robust applications. Its powerful static type system, built on top of JavaScript, helps developers catch errors early, improve code readability, and enhance team collaboration. One of the most fundamental and versatile features of this type system is the TypeScript Union Type. A union type allows a variable, parameter, or property to hold a value of one of several distinct types. It’s like telling the compiler, “This value can be a `string`, OR it can be a `number`.” This flexibility is key to accurately modeling real-world data structures, which often don’t fit into a single, rigid type. This article provides a comprehensive tutorial on TypeScript Union Types, starting from the basics and progressing to advanced patterns like discriminated unions, complete with practical examples for functions, asynchronous operations, and DOM manipulation. Whether you’re working with TypeScript in React, Node.js, Angular, or Vue, mastering union types is essential for writing clean, type-safe, and maintainable code.
Core Concepts: Defining and Working with Unions
At its heart, a union type is a way to combine multiple types into one. You create a union type by using the pipe symbol (|
) between the types you want to include. This simple syntax unlocks a powerful way to handle variables that can legitimately hold different kinds of values.
Defining a Simple Union Type
Let’s start with a basic example. Imagine you have a function that needs to accept an identifier which could be either a numeric ID or a string-based UUID. A union type is the perfect solution.
// The 'id' parameter can be either a number or a string.
function findUserById(id: number | string) {
console.log(`Searching for user with ID: ${id}`);
// ... implementation
}
findUserById(101); // Valid
findUserById("abc-123"); // Valid
// findUserById(true); // Error: Argument of type 'boolean' is not assignable to parameter of type 'string | number'.
While this provides flexibility, it also introduces a challenge. When TypeScript sees a variable with a union type, it will only allow you to perform operations that are valid for every type in the union. For instance, you can’t use string methods on our id
parameter because it might be a number, and you can’t do arithmetic because it might be a string.
Type Narrowing: Making Unions Usable
To work around this limitation, we must use a technique called type narrowing. This involves performing a runtime check that informs the TypeScript compiler about the specific type of a value within a certain block of code. The most common way to do this for primitive types is with the typeof
operator.
function processIdentifier(id: number | string) {
if (typeof id === "string") {
// Inside this block, TypeScript knows 'id' is a string.
// We can safely use string methods.
console.log(id.toUpperCase());
} else {
// Inside this block, TypeScript knows 'id' must be a number.
// We can safely perform arithmetic.
console.log(id.toFixed(2));
}
}
processIdentifier("user-xyz"); // Outputs: USER-XYZ
processIdentifier(42.12345); // Outputs: 42.12
This process of checking and narrowing is fundamental to using union types effectively. It allows you to combine flexibility with the type safety that TypeScript is known for, ensuring you don’t accidentally call .toUpperCase()
on a number.
Practical Implementations in Real-World Scenarios
Union types are not just a theoretical concept; they appear everywhere in practical application development, from handling function arguments to managing asynchronous data and interacting with the browser’s Document Object Model (DOM).
Asynchronous API Calls with Union Types
When fetching data from an API, the operation can either succeed or fail. This is a perfect use case for a union type to model the response. We can define a union that represents either a successful result or an error object. This pattern is extremely common in TypeScript projects, especially when using libraries like Axios in a TypeScript Node.js or React application.
// Define the shape of a successful response and an error response
interface User {
id: number;
name: string;
email: string;
}
interface ApiError {
code: number;
message: string;
}
// Create a union type for the API response
type ApiResponse = { status: 'success'; data: User } | { status: 'error'; error: ApiError };
async function fetchUserData(userId: number): Promise<ApiResponse> {
try {
const response = await fetch(`https://api.example.com/users/${userId}`);
if (!response.ok) {
// Return the error shape
return {
status: 'error',
error: { code: response.status, message: 'Failed to fetch user.' },
};
}
const user: User = await response.json();
// Return the success shape
return { status: 'success', data: user };
} catch (e) {
return {
status: 'error',
error: { code: 500, message: 'A network error occurred.' },
};
}
}
// Usage
async function displayUser() {
const result = await fetchUserData(1);
if (result.status === 'success') {
// TypeScript knows `result.data` exists here
console.log(`User Name: ${result.data.name}`);
} else {
// TypeScript knows `result.error` exists here
console.error(`Error: ${result.error.message} (Code: ${result.error.code})`);
}
}
In this example, the status
property acts as a flag that helps us differentiate between the two possible shapes of our ApiResponse
type. This is a preview of a more advanced pattern called a discriminated union.
Safe DOM Manipulation
If you’ve ever worked with the DOM, you know that methods like document.querySelector()
don’t always find an element. In vanilla JavaScript, this results in a value of null
, which can lead to runtime errors if not handled. TypeScript models this behavior perfectly with a union type: Element | null
. This forces you to check for null
before attempting to manipulate the element, preventing common “Cannot read properties of null” errors.
const container = document.querySelector('#app-container');
// Type of 'container' is HTMLElement | null
if (container) {
// Inside this block, TypeScript has narrowed the type to HTMLElement.
// It's safe to access properties like innerHTML.
container.innerHTML = '<h1>Hello, TypeScript!</h1>';
} else {
console.error('Could not find the container element!');
}
// This would cause a compiler error because 'container' could be null.
// container.innerHTML = 'This is unsafe!'; // Error: Object is possibly 'null'.
Advanced Patterns: Discriminated Unions
While simple unions and typeof
checks are great for primitives, a more powerful pattern is needed for unions of object types: the discriminated union (also known as a tagged union or algebraic data type). This pattern is a cornerstone of robust state management in TypeScript applications, particularly in frameworks like TypeScript React (using reducers) and NestJS.
What is a Discriminated Union?
A discriminated union is a union of object types that all share a common property with a unique literal type. This common property, the “discriminant,” allows TypeScript to perform extremely effective type narrowing.
Let’s model a set of events in an application. Each event has a different type and may carry a different payload.
interface PageLoadEvent {
type: 'PAGE_LOAD'; // The discriminant
timestamp: number;
}
interface ClickEvent {
type: 'CLICK'; // The discriminant
element: string; // e.g., 'button#submit'
x: number;
y: number;
}
interface FormSubmitEvent {
type: 'FORM_SUBMIT'; // The discriminant
formData: Record<string, string>;
}
type AppEvent = PageLoadEvent | ClickEvent | FormSubmitEvent;
function handleEvent(event: AppEvent) {
// We can use a switch statement on the discriminant property 'type'
switch (event.type) {
case 'PAGE_LOAD':
// TypeScript knows 'event' is a PageLoadEvent here.
console.log(`Page loaded at: ${new Date(event.timestamp).toISOString()}`);
break;
case 'CLICK':
// TypeScript knows 'event' is a ClickEvent here.
console.log(`Clicked ${event.element} at (${event.x}, ${event.y})`);
break;
case 'FORM_SUBMIT':
// TypeScript knows 'event' is a FormSubmitEvent here.
console.log('Form submitted with data:', event.formData);
break;
default:
// This part is for exhaustiveness checking
const _exhaustiveCheck: never = event;
console.error('Unhandled event type:', _exhaustiveCheck);
return _exhaustiveCheck;
}
}
The Power of Exhaustiveness Checking
The default
case in the `switch` statement above demonstrates a powerful TypeScript pattern called exhaustiveness checking. By assigning the `event` to a variable of type `never`, we are telling the compiler that this code path should be unreachable. If we add a new event type to the AppEvent
union (e.g., KeyPressEvent
) but forget to add a `case` for it in our `handleEvent` function, TypeScript will throw a compile-time error. This is because the new event type could fall through to the `default` case, and a `KeyPressEvent` cannot be assigned to `never`. This technique ensures that we never forget to handle all possible variants of our union type, making our code much more robust and easier to refactor.
Best Practices and Common Pitfalls
To get the most out of union types, it’s important to follow some best practices and be aware of common pitfalls that can undermine type safety.
Best Practices for Union Types
- Prefer Discriminated Unions for Objects: Whenever you have a union of different object shapes, add a literal discriminant property (like
type
,kind
, orstatus
). This enables powerful and safe type narrowing with `switch` statements and exhaustiveness checking. - Use Type Aliases: Define complex unions with the
type
keyword. This makes your code more readable and reusable. Instead of writingstring | number | null
everywhere, definetype ID = string | number | null;
. - Keep Unions Focused: Avoid creating overly broad unions like
string | number | boolean | User | Product | null | undefined
. If a type becomes too generic, it loses its descriptive power and can become a glorified version ofany
, defeating the purpose of TypeScript.
Common Pitfalls to Avoid
- Forgetting to Narrow: A frequent mistake is trying to access a property that only exists on one type within the union without first narrowing the type. The TypeScript compiler will always flag this, so pay close attention to these errors.
- Relying on Property Presence Alone: Without a discriminant, checking for the existence of a property (e.g.,
if ('data' in response)
) can be a valid type guard. However, it can become brittle if object shapes overlap or change. Discriminated unions are almost always a safer and clearer approach. - Overlapping Object Shapes: Be careful with unions of objects that are not discriminated and share property names. For example, with
{ status: string, data: any } | { status: string, message: string }
, checkingstatus
doesn’t help you narrow the type, as it exists on both.
Conclusion
TypeScript Union Types are a cornerstone feature for any developer looking to build flexible, yet type-safe applications. They provide the expressive power to model data and states that aren’t confined to a single shape. By understanding the core concepts of defining unions, the necessity of type narrowing, and the power of advanced patterns like discriminated unions with exhaustiveness checking, you can significantly improve the quality and reliability of your code. Whether you are handling API responses in a TypeScript Express backend, managing component state in a TypeScript React project, or ensuring safe DOM interactions, union types are an indispensable tool in your TypeScript toolkit. As a next step, consider exploring how union types interact with other powerful features like Intersection Types and TypeScript Utility Types (e.g., Pick
, Omit
, Exclude
) to further enhance your type-level programming skills.