Working with Union Types In TypeScript

Updated on · 6 min read
Working with Union Types In TypeScript

TypeScript, as a strongly typed superset of JavaScript, brings additional layers of structure and safety to the development process. It introduces a robust typing system on top of the dynamic nature of JavaScript, helping developers catch errors early in the development cycle and thus significantly reducing runtime errors. Among the various features TypeScript offers, like generic types, type guards, template literal types, and mapped types, union types stand out as a versatile and powerful tool that allows variables to store values of more than one type.

Union types are an answer to many common programming dilemmas, offering a way to work with variables that might hold values of different types at different times. For instance, a function that processes user input might accept both a string and a number. Without union types, developers would need to overload the function or weaken type checks by using the any type, losing the benefits of TypeScript's static typing in the process.

This post will explore the concept of union types in TypeScript, focusing on their syntax, type narrowing techniques, and practical applications. By the end, you will have a comprehensive understanding of how union types work in TypeScript and how they can be effectively used to enhance the robustness and flexibility of your code.

Understanding union types

At its core, a union type in TypeScript is a type formed by combining two or more types, allowing a variable to hold values of the specified types. Contrary to intersection types, which require a variable to satisfy all the specified types, union types provide flexibility by allowing a variable to be of any of the specified types at a given time. This feature is particularly useful when a variable or function parameter can accept multiple types of data, providing a flexible and type-safe way to handle diverse data structures and logic.

Defining union types

At the most fundamental level, a union type is declared by using the pipe (|) symbol between two or more types. This simple yet powerful feature allows a variable to hold values of any of the specified types. For example, a variable that can store either a string or a number would be declared as string | number. This syntax is straightforward but provides a substantial degree of flexibility in how data is represented and manipulated.

Consider the following example:

typescript
let userId: string | number; userId = 123; // Valid userId = "abc123"; // Also valid
typescript
let userId: string | number; userId = 123; // Valid userId = "abc123"; // Also valid

In the above scenario, userId can hold both a numeric or string type, reflecting a common case where an entity might be identified by either a number (like an auto-incremented database ID) or a string (like a username).

Defining union types extends beyond primitive types like string and number to more complex constructs, including interfaces and arrays. This allows developers to construct versatile data structures and function parameters, further widening the scope of union types' applicability.

Type narrowing

While union types introduce flexibility, they also require a special approach to type safety. That's where type narrowing and type guards come into play. Type narrowing ensures that at any given point in the code, TypeScript knows the exact type of the variable, thus allowing access to type-specific properties and methods.

TypeScript offers several ways to narrow types:

  • Using typeof operator: Commonly used for primitive types to differentiate between the basic types.
typescript
if (typeof userId === "string") { console.log(userId.toUpperCase()); // This is safe because TypeScript knows `userId` is a string here. } else { console.log(userId); }
typescript
if (typeof userId === "string") { console.log(userId.toUpperCase()); // This is safe because TypeScript knows `userId` is a string here. } else { console.log(userId); }
  • Using instanceof operator: Used to check if an object is an instance of a specific class or constructor function.
typescript
class ImageFile extends File { resolution: string; constructor(name: string, resolution: string) { super(name); this.resolution = resolution; } } function processFile(file: File | ImageFile) { if (file instanceof ImageFile) { console.log(file.resolution); // TypeScript knows `file` is an `ImageFile` here. } else { console.log(file.name); // TypeScript knows `file` is a `File` here. } }
typescript
class ImageFile extends File { resolution: string; constructor(name: string, resolution: string) { super(name); this.resolution = resolution; } } function processFile(file: File | ImageFile) { if (file instanceof ImageFile) { console.log(file.resolution); // TypeScript knows `file` is an `ImageFile` here. } else { console.log(file.name); // TypeScript knows `file` is a `File` here. } }
  • Custom type guards: Functions that return a boolean, indicating whether the input is of a specific type or not.
typescript
interface Book { type: "book"; title: string; author: string; pages: number; } interface Clothing { type: "clothing"; name: string; size: string; material: string; } type StoreItem = Book | Clothing; // Custom type guard to determine if a StoreItem is a Book function isBook(item: StoreItem): item is Book { return item.type === "book"; }
typescript
interface Book { type: "book"; title: string; author: string; pages: number; } interface Clothing { type: "clothing"; name: string; size: string; material: string; } type StoreItem = Book | Clothing; // Custom type guard to determine if a StoreItem is a Book function isBook(item: StoreItem): item is Book { return item.type === "book"; }

These methods help TypeScript combine the versatility of union types with the reliability of static typing. By narrowing types, it is possible to create code that's flexible yet solid.

Practical applications of union types

Union types in TypeScript aren't just theoretical—they shine in real-world development scenarios. This section demonstrates some of the best use cases for union types and their practical applications.

Handling heterogeneous data

One of the most common uses of union types is in the handling of data that may not conform to a single type. In many applications, especially those consuming external APIs or working with user-generated content, it's typical to encounter data structures that can hold multiple types of data.

For instance, let's consider an application that fetches a list of items where each item could be identified by either a string (name) or a number (ID). While this approach to storing data is not optimal and should be probably improved on the backend side, we often don't have much control over the data structures received from external sources. In such cases, using union types to model the items allows for a cleaner and more intuitive approach to processing this data:

typescript
type ItemIdentifier = string | number; function getItem(id: ItemIdentifier): void { // Function logic here }
typescript
type ItemIdentifier = string | number; function getItem(id: ItemIdentifier): void { // Function logic here }

This approach simplifies the data model and reduces the need for excessive type checks or conversions, leaving it to TypeScript's type narrowing capabilities to handle the specifics.

Enhancing function overloading with union types

While function overloading in TypeScript offers a way to have multiple function signatures, its traditional form can sometimes limit flexibility, particularly when dealing with inputs that could be of multiple types. This is where transforming an overloaded function into a non-overloaded version creates a more flexible function, making it easier to call, understand, and implement.

Let's consider a function designed to add a new entry to a log, which could be a textual message or an actionable alert represented by an object.

typescript
interface Alert { message: string; severity: "low" | "medium" | "high"; } function logEntry(message: string): void; function logEntry(alert: Alert): void; function logEntry(entry: any) { if (typeof entry === "string") { console.log(`Log message: ${entry}`); } else { console.log(`Alert: ${entry.message} (Severity: ${entry.severity})`); } } logEntry("Server started successfully."); // Text log logEntry({ message: "Memory usage high", severity: "high" }); // Alert log
typescript
interface Alert { message: string; severity: "low" | "medium" | "high"; } function logEntry(message: string): void; function logEntry(alert: Alert): void; function logEntry(entry: any) { if (typeof entry === "string") { console.log(`Log message: ${entry}`); } else { console.log(`Alert: ${entry.message} (Severity: ${entry.severity})`); } } logEntry("Server started successfully."); // Text log logEntry({ message: "Memory usage high", severity: "high" }); // Alert log

While this works in simpler cases, it cannot dynamically handle input types that could be either a string or an Alert object.

typescript
logEntry( Math.random() > 0.5 ? "A simple log entry" : { message: "Urgent issue", severity: "high" }, ); // Error: No overload matches this call.
typescript
logEntry( Math.random() > 0.5 ? "A simple log entry" : { message: "Urgent issue", severity: "high" }, ); // Error: No overload matches this call.

Using union types instead of the overloaded function signatures, we can create a single function that can handle both types seamlessly:

typescript
function logEntry(entry: string | Alert): void { if (typeof entry === "string") { console.log(`Log message: ${entry}`); } else { console.log(`Alert: ${entry.message} (Severity: ${entry.severity})`); } } logEntry("User logged in."); logEntry({ message: "Disk space running low", severity: "medium" }); logEntry( Math.random() > 0.5 ? "A simple log entry" : { message: "Urgent issue", severity: "high" }, ); // Now works for both types
typescript
function logEntry(entry: string | Alert): void { if (typeof entry === "string") { console.log(`Log message: ${entry}`); } else { console.log(`Alert: ${entry.message} (Severity: ${entry.severity})`); } } logEntry("User logged in."); logEntry({ message: "Disk space running low", severity: "medium" }); logEntry( Math.random() > 0.5 ? "A simple log entry" : { message: "Urgent issue", severity: "high" }, ); // Now works for both types

By adopting a single function signature that incorporates a union type (string | Alert), we've made the logEntry function flexible enough to accept either type of input directly, including dynamically determined types at runtime.

Complex conditional rendering

When working with JavaScript frameworks like React, union types can be quite helpful in implementing conditional rendering logic. By defining props or state as union types, components can render different UI elements based on the current type of the data.

For example, a component might need to display either a loading indicator, an error message, or a list of items, based on the current state of an API request.

tsx
type LoadState = | { status: "loading" } | { status: "error"; error: Error } | { status: "loaded"; items: Item[] }; function RenderState(state: LoadState) { switch (state.status) { case "loading": return <LoadingIndicator />; case "error": return <ErrorMessage error={state.error} />; case "loaded": return <ItemList items={state.items} />; default: return null; } }
tsx
type LoadState = | { status: "loading" } | { status: "error"; error: Error } | { status: "loaded"; items: Item[] }; function RenderState(state: LoadState) { switch (state.status) { case "loading": return <LoadingIndicator />; case "error": return <ErrorMessage error={state.error} />; case "loaded": return <ItemList items={state.items} />; default: return null; } }

The practical applications of union types in TypeScript are broad and varied, extending across domains and project types. By enabling developers to more accurately model the data and logic of their applications, union types serve as a key tool in creating flexible, robust, and maintainable TypeScript code. The benefits, as demonstrated through handling heterogeneous data, function overloading, and conditional rendering, reinforce the importance of understanding and effectively utilizing union types in development projects.

Conclusion

In conclusion, union types are a fundamental concept that every TypeScript developer should be familiar with. Their proper use can significantly enhance the development experience, resulting in applications that are not only more robust and easier to maintain but also more aligned with the dynamic nature of data and user interaction.

In this post, we have explored the concept of the union types in TypeScript, their syntax, type narrowing techniques, and practical applications. Through various examples, it was shown how union types can enhance the robustness and flexibility of code, showcasing their importance in real-world development scenarios.

References and resources