Understanding the thought pattern behind the union and the intersection types in TypeScript helps you learn how better to implement them when necessary.
In this article, we will explore the concept of union and intersection. First, we’ll look at the mathematical definition of these concepts used in set theory. Then, we’ll examine the definition and usage of the concept of union and intersection in TypeScript.
Set theory provides the implicit concept that most of us use when thinking about union and intersection.
Here are two sets of numbers arranged in ascending order: D = { 0, 1, 2, 3 }
and B = { 1, 2, 3, 4, 5 }
.
This below definition explores what union and intersection are in relationship to the above defined sets:
The union of the sets D and B, denoted as D ⋃ B, is a set of all elements that are included in (contained in) either D, or B, or both. The union of the set D and the set B is, { 0, 1, 2, 3, 4, 5 }
.
Intersection of the sets D and B, denoted as D ⋂ B, is a set of all elements that are in both D and B. The intersection of the set D and the set B is, { 1, 2, 3 }
.
Most approaches when trying to understand the above concepts comes from this definition and usage in set theory.
In TypeScript, union and intersection types are used to compose or model types from existing types.
These new composed types behave differently, depending on whether they were composed through a union or intersection of the existing type they were formed from.
Defined in the Advanced Types section of Typescript, an intersection type is a type that combines several types into one; a value that can be any one of several types is a union type.
The &
symbol is used to create an intersection, whereas the |
symbol is used to represent a union. An intersection can be read as an And
operator and a union as an Or
.
Let’s explore further how this applies to our TypeScript code.
Using primitive types in computer science, a type’s values are its instances. Let’s look at a hypothetical type, Char
, which holds no properties, but still represents an interval of values, as seen here:
char = { -128, ..., 127 } // Char type from -128 to 127 unsignedChar = { 0, ..., 255 } // UnsignedChar type from 0 to 255
The intersection of the above two types gives us a new type:
// typescript type PositiveChar = Char & UnsignedChar // unsignedChar ∩ char <=> { 0, ..., 127 }
The union of the above two types gives us another new type that is able to hold both string
and number
:
// typescript string // any char array number // any number type PrimitiveType = string | number
The most common mistake is to believe that a type’s values are its characteristics (properties) — rather, the values of a type are its instances.
As a result, the intersection has fewer values than its components and can be used in any function that is designated for them.
Similarly, union produces a new, less rigid, more open type — it should be noted that you can’t be certain that the functions associated with this new type are still valid.
Now, let’s follow up the above definitions with how we can implement union and intersection in our TypeScript code.
You will want to write a function that expects a parameter to be either a number or a string and executes a part of the function code depending on the argument passed to it.
Take, for example, the following function:
function direction(param: any) { if (typeof param === "string") { ... } if (typeof param === "number") { ... } ... }
In the above code, we expect an argument with the type either as string
or number
, which forms a new type, but we have a problem with the above implementation.
With the type any
, we can end up being able to pass types that are not string
or number
; but we want to strictly make the function exclusively accept a string
or number
type.
With the help of union, we can re-implement the above code snippet to receive the value of a string
or number
instance exclusively.
function direction(param: string | number ) { ... }
We can extend our above value type to involve more primitive types like a Boolean value, and if we passed value instances beyond our union type definition such as a boolean
, the code panics with an error message like this:
Argument of type 'boolean' is not assignable to parameter of type 'string | number'.
To aid the compiler, we take our union definition up a little further using discriminated union, also referred to as tagged union type. Discriminated union is a data structure used to hold a value that could take on different, fixed types. These are basically union types with a tag.
To convert a union type into a discriminated union type, we use a common property across our types. This property can be any name and will serve as an ID for the different types. Every type will have a different literal type for that property.
// TypeScript type LoadingState = { state: "loading"; // tag }; type FailedState = { state: "failed"; // tag status: number; }; type SuccessState = { state: "success"; // tag response: { isLoaded: boolean; }; };
When we use the above type definition to compose a new type and use it against a pattern-matching process within our code, we can easily learn what part of our type we are trying to access.
type State = LoadingState | FailedState | SuccessState; function request(state: State): string { switch (state.state) { case "loading": return "Uploading..."; case "failed": return `Error status: ${state.status}, while Uploading`; case "success": return `Uploaded to cloud: ${state.response.isLoaded} `; } }
Close observation will show how we’re helping the compiler make decisions depending on what part of the code we’re executing.
Trying to call the state.state.status
outside the switch
will raise a warning that the property doesn’t exist on the type. However, it’s safe to try to access the status
field when executing it under the case "failed"
switch statement.
One of the benefits of using a discriminated union is that you get a type guard for all the discriminations of the union types.
An easy way to understand intersections involves error handling.
Imagine you want to implement a type for reading and writing to a file. We handle errors that could occur when we try to read or write to a file because this way, you enforce that such types contain a type for handling the error or success while reading and writing to a file.
interface ErrorHandling { success: boolean; error?: { message: string }; } interface File { content: { lines: string }[]; } type FileReader = File & ErrorHandling; const writeToAFile = (response: FileReader) => { if (response.error) { console.error(response.error.message); return; } console.log(response.content); };
Intersection types can only contain a subset of their components’ instances, but they can use any of their functions. An intersection type combines multiple types into one.
It is possible to achieve conditional intersection through the union type. From the example above, we used intersection type which combined the Files
and ErrorHandling
types and assigned it to the FilesReader
type. Now if we pass a wrong type, TypeScript will throw an error.
type FilesReader = Files & ErrorHandling; FilesReader = { success: "false", // Type 'string' is not assignable to type 'boolean' error: {message: "not found"}, content: "no file" // Type 'string' is not assignable to type '{ lines: string; }[]' }
If for any reason a property with a type neither found in the Files
type nor the ErrorHandling
type is set, the TypeScript compiler throws an error. To prevent this, we use conditional intersection via the union type, like so:
type FilesReader = (Files & ErrorHandling) | any; FilesReader = { success: "false", error: {message: "not found"}, content: "no file" }
Now when we pass a wrong type for any property of FilesReader
(just like wrong types are being passed for the success
and content
properties), no error is thrown because conditionally, we’ve set the FilesReader
type to any
. You can test this example on the TypeScript playground.
As a TypeScript developer, you’ll frequently bump into the “property does not exist on type union” error when working with union types. This error occurs when you try to access a property that doesn’t exist on every object on the union type. Let’s say we have two types, Mail
and Phone
, like so:
type Mail = { message: string } type Phone = { text: string } // trying to access the message property const info = (obj: Mail | Phone): string => { if (obj.message) { return obj.message } return obj.text }
As we try to access the message property, we’ll get an error like this:
// Property 'message' does not exist on type 'Mail | Phone'.
This error occurs because the message
property is not present in all the types in the union, i.e., message
isn’t present in the Phone
type.
We solve this error by using a type guard to ensure the property exists on the object before accessing it. One way to do this is to narrow down the type by using the [in]
operator like so:
const info = (obj: Mail | Phone): string => { if ("message" in obj) { return obj.message } return obj.text }
The in
operator returns true
if a specified property is in an object or its prototype chain. This will allow TypeScript to infer the correct object type in the if
block.
This is good approach to follow when trying to understand union and intersection in TypeScript.
We can combine numerous types into one with an intersection type. The structure of an object with an intersection type must include all of the kinds that make up the intersection types; it’s made up of various types joined together by an &
symbol.
Union types produce a new type that allows us to create objects with some or all of the properties of each of the types that make up the union type. The pipe |
symbol is used to join multiple types to form union types and allows us to create a new type that inherits some of the structure of union types.
LogRocket is a frontend application monitoring solution that lets you replay problems as if they happened in your own browser. Instead of guessing why errors happen or asking users for screenshots and log dumps, LogRocket lets you replay the session to quickly understand what went wrong. It works perfectly with any app, regardless of framework, and has plugins to log additional context from Redux, Vuex, and @ngrx/store.
In addition to logging Redux actions and state, LogRocket records console logs, JavaScript errors, stacktraces, network requests/responses with headers + bodies, browser metadata, and custom logs. It also instruments the DOM to record the HTML and CSS on the page, recreating pixel-perfect videos of even the most complex single-page and mobile apps.
Hey there, want to help make our blog better?
Join LogRocket’s Content Advisory Board. You’ll help inform the type of content we create and get access to exclusive meetups, social accreditation, and swag.
Sign up nowLearn how to implement one-way and two-way data binding in Vue.js, using v-model and advanced techniques like defineModel for better apps.
Compare Prisma and Drizzle ORMs to learn their differences, strengths, and weaknesses for data access and migrations.
It’s easy for devs to default to JavaScript to fix every problem. Let’s use the RoLP to find simpler alternatives with HTML and CSS.
Learn how to manage memory leaks in Rust, avoid unsafe behavior, and use tools like weak references to ensure efficient programs.