Union types in TypeScript allow a variable or a function to accept multiple data types. This gives us flexibility in defining types and helps in situations where values can be of more than one type.
TypeScript still performs type checks at compile time, so we retain most of the advantages of static typing.
Declaring Union Types
To declare a union type, use the vertical bar operator (|) between the types you want to combine. This indicates that a variable can be of one type or the other.
let value: number | string;
value = 42; // Correct
value = "text"; // Correct
value = true; // ❌ Error: Type 'boolean' cannot be assigned to type 'number | string'.
In the example above, the variable value can hold a number or a string, but not a boolean value.
Using Union Types in Functions
Functions can also use union types for their parameters and return values.
function printValue(value: number | string): void {
console.log(value);
}
printValue(123); // Correct
printValue("string"); // Correct
printValue(true); // ❌ Error: Type 'boolean' cannot be assigned to type 'number | string'.
Union Types in Object Properties
We can use union types in the properties of interfaces and types to provide flexibility for properties within an object.
interface Product {
id: number;
name: string;
price: number | string; // The price property can be a number or a string
}
// product1 has number as price
let product1: Product = {
id: 1,
name: "Product A",
price: 100
};
// product2 has string as price
let product2: Product = {
id: 2,
name: "Product B",
price: "One hundred dollars"
};
In this example, the price property of the Product object can be a number or a string.
Narrowing (Type Narrowing)
When working with union types, it’s common that at some point we need to determine the specific type of a variable at runtime.
This is known as “narrowing” and can be achieved using type checks (type guards)
Using typeof
The typeof operator is useful for narrowing types when working with primitive types like numbers and strings.
function processValue(value: number | string): void {
// Checks if the value is a number
if (typeof value === "number") {
console.log(`The value is a number: ${value}`);
} else {
// If it's not a number, it must be a string
console.log(`The value is a string: ${value}`);
}
}
processValue(123); // The value is a number: 123
processValue("text"); // The value is a string: text
Using instanceof
The instanceof operator is used to check if an object is an instance of a specific class, which is useful for type narrowing with classes and complex objects.
class Dog {
bark() {
console.log("Woof!");
}
}
class Cat {
meow() {
console.log("Meow!");
}
}
function makeSound(animal: Dog | Cat): void {
// Checks if the animal is an instance of Dog
if (animal instanceof Dog) {
animal.bark(); // If it's a Dog, it barks
} else {
animal.meow(); // If it's not a Dog, it must be a Cat and it meows
}
}
let myDog = new Dog();
let myCat = new Cat();
makeSound(myDog); // Woof!
makeSound(myCat); // Meow!
Custom Checks
It’s also possible to define custom type checks (type predicates) to narrow types more precisely.
// interface bird
interface Bird {
fly(): void;
feathers: number;
}
// interface fish
interface Fish {
swim(): void;
fins: number;
}
// Function that determines if an animal is a Bird
function isBird(animal: Bird | Fish): animal is Bird {
// Checks if the 'fly' method is defined on the object
return (animal as Bird).fly !== undefined;
}
function doSomething(animal: Bird | Fish): void {
if (isBird(animal)) {
animal.fly(); // If it's a Bird, it flies
} else {
animal.swim(); // If it's not a Bird, it must be a Fish and swims
}
}
Using Aliases with Union Types
You can use type aliases to make union types clearer and more concise.
type ID = number | string;
function getUser(id: ID): void {
// Logic to get user
}
In this example, we create an Alias ID that can be number or string.
Unions of Compound Types
Union types can discriminate even when the types are part of other types or objects.
type ApiResponse = { data: any; error: null } | { data: null; error: string };
function handleResponse(response: ApiResponse): void {
if (response.error) {
console.error(`Error: ${response.error}`);
} else {
console.log(`Data: ${response.data}`);
}
}
let response: ApiResponse = { data: { id: 1, name: "Luis" }, error: null };
handleResponse(response); // Data: { id: 1, name: "Luis" }
In this example,
ApiResponsecan be a response with data and no errors, or with no data and errors.- The code handles both possibilities appropriately.
