As developers, we often face scenarios where a variable or a function parameter could legitimately hold more than one type of value. Maybe an ID can be a number or a string, or a function can accept different but related object shapes. In vanilla JavaScript, we’d handle this with runtime checks, but in TypeScript, we can achieve this with full type safety using Union Types.
Let’s dive into what union types are, how to use them, and the powerful patterns they enable.
What are Union Types?
A union type allows you to define a type that can be one of several possible types. You create a union type by using the pipe (|) symbol between the types.
Think of it as an “OR” for types. A variable of type string | number can hold a value that is either a string or a number.
let userId: string | number;
userId = 101; // This is valid
console.log(userId);
userId = "user-abc-123"; // This is also valid
console.log(userId);
// userId = true; // Error: Type 'boolean' is not assignable to type 'string | number'.
This simple concept is incredibly powerful and can be applied in various ways.
Common Examples of Union Types
1. Union of Primitive Types
This is the most straightforward use case, as seen above. It’s perfect for when a value can be of different basic types.
function printId(id: number | string) {
console.log(Your ID is: ${id});
}
printId(404); // Works
printId("E-404"); // Works
// printId({ id: 404 }); // Error: Argument of type '{ id: number; }' is not assignable to parameter of type 'string | number'.
2. Union of Literal Types
You can use union types to create a set of specific, allowed string or number values. This acts like a simple, lightweight enum.
type Alignment = "left" | "right" | "center";
function setAlignment(align: Alignment) {
// ... logic to set text alignment
console.log(Alignment set to: ${align});
}
setAlignment("center"); // Works
setAlignment("left"); // Works
// setAlignment("justify"); // Error: Argument of type '"justify"' is not assignable to parameter of type 'Alignment'.
3. Union of Object Types
This is where unions truly shine. You can model situations where you might receive different, but related, object structures. For example, a system might have Users and Admins.
interface User {
id: number;
name: string;
}
interface Admin {
id: number;
name: string;
permissions: string[];
}
type AppUser = User | Admin;
function welcomeUser(user: AppUser) {
console.log(Welcome, ${user.name}!);
}
const regularUser: User = { id: 1, name: "Ankur" };
const adminUser: Admin = { id: 2, name: "Neha", permissions: ["create-post", "delete-user"] };
welcomeUser(regularUser); // Works
welcomeUser(adminUser); // Works
The Challenge: Working with Union Types
When you have a value of a union type, TypeScript will only allow you to access properties or methods that are common to all types in the union.
In our AppUser example, both User and Admin have id and name, so we can access them without any issue. But what if we try to access the permissions property, which only exists on Admin?
function checkPermissions(user: AppUser) {
// console.log(user.permissions); // Error: Property 'permissions' does not exist on type 'AppUser'.
// Property 'permissions' does not exist on type 'User'.
}
TypeScript throws an error because it can’t guarantee that user is an Admin. It could be a User, which doesn’t have a permissions property. This is a safety feature to prevent runtime errors.
The Solution: Type Narrowing
To work around this, we need to “narrow” the type. We perform a check to help TypeScript understand which specific type we are dealing with within a certain block of code. There are several ways to do this.
1. Using typeof for Primitives
The typeof operator is perfect for narrowing union types of primitives like string, number, or boolean.
function processValue(value: string | number) {
if (typeof value === "string") {
// Inside this block, TypeScript knows value is a string.
console.log(value.toUpperCase());
} else {
// Inside this block, TypeScript knows value is a number.
console.log(value.toFixed(2));
}
}
2. Using the in Operator for Objects
For object types, you can use the in operator to check for the existence of a property that is unique to one of the types.
function manageUser(user: AppUser) {
if ("permissions" in user) {
// TypeScript now knows user is an Admin inside this block.
console.log(Admin ${user.name} has permissions: ${user.permissions.join(", ")});
} else {
// Here, TypeScript knows user must be a User.
console.log(User ${user.name} has no special permissions.);
}
}
manageUser(adminUser); // "Admin Neha has permissions: create-post, delete-user"
manageUser(regularUser); // "User Ankur has no special permissions."
3. Discriminated Unions (The Best Pattern)
A more robust and scalable way to narrow object unions is to use a “discriminated union.” This involves adding a common property (the “discriminant”) to each type in the union, which has a unique literal type.
Let’s refactor our User and Admin types to use a common type property.
interface DiscriminatedUser {
type: "USER"; // Discriminant
id: number;
name: string;
}
interface DiscriminatedAdmin {
type: "ADMIN"; // Discriminant
id: number;
name: string;
permissions: string[];
}
type DiscriminatedAppUser = DiscriminatedUser | DiscriminatedAdmin;
function handleAppUser(user: DiscriminatedAppUser) {
switch (user.type) {
case "ADMIN":
// TypeScript knows user is a DiscriminatedAdmin here.
console.log(Admin with permissions: ${user.permissions});
break;
case "USER":
// TypeScript knows user is a DiscriminatedUser here.
console.log(Regular user: ${user.name});
break;
default:
// This helps ensure you've handled all cases.
const _exhaustiveCheck: never = user;
return _exhaustiveCheck;
}
}
This pattern is highly recommended because it’s explicit, less prone to errors from property renaming, and works beautifully with switch statements.
Conclusion
Union types are a fundamental feature in TypeScript that provides a perfect balance between flexibility and safety. They allow you to model real-world data structures accurately while forcing you to write safer code through type-narrowing checks.
- Flexibility: Define variables, parameters, and return types that can handle multiple data shapes.
- Type Safety: Prevent runtime errors by compelling you to check for a specific type before accessing its unique properties.
- Clarity: Your code becomes more self-documenting, as the types clearly state what kind of data is expected.
By mastering union types and a few narrowing techniques, you can write more robust, readable, and maintainable TypeScript applications.