Ikeh Akinyemi Ikeh Akinyemi is a software engineer based in Rivers State, Nigeria. He’s passionate about learning pure and applied mathematics concepts, open source, and software engineering.

Understanding discriminated union and intersection types in TypeScript

6 min read 1703

Understanding Discriminated Union Intersection Types Typescript

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.

Union and intersection in set theory

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.

Union and intersection types in TypeScript

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.


More great articles from LogRocket:


Now, let’s follow up the above definitions with how we can implement union and intersection in our TypeScript code.

Union types

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'.

Discriminated union

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.

Conditional intersection via union type

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.

Handling type union error

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.

Conclusion

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.

Writing a lot of TypeScript? Watch the recording of our recent TypeScript meetup to learn about writing more readable code.

TypeScript brings type safety to JavaScript. There can be a tension between type safety and readable code. Watch the recording for a deep dive on some new features of TypeScript 4.4.

Ikeh Akinyemi Ikeh Akinyemi is a software engineer based in Rivers State, Nigeria. He’s passionate about learning pure and applied mathematics concepts, open source, and software engineering.

Leave a Reply