DEV Community

Cover image for Strange Magic of Typescript Intersections
Andy
Andy

Posted on

Strange Magic of Typescript Intersections

During a recent code review, I stumbled upon an intersection type that appeared not to be fully unbderstandable to me. Intrigued by its usage, I embarked on a journey to delve deeper into the inner workings of TypeScript intersections. In this article, I will share my findings and shed light on how intersections can be used and which gotchas you may face.

What is intersections?

In a nutshell, TypeScript intersections offer a means of combining multiple types into a single cohesive type. By merging various types, intersections enable we can create composite types that inherit the properties and methods of their constituent types.

An intersection in TypeScript is defined by using the & symbol between the types to be merged. Let's explore a simple example to illustrate this concept:

type Student = {
  id: string;
  name: string;
};

type Settings = {
  theme: 'dark' | 'light';
  isLocalTimezone: boolean;
};

type StudentsSettings = Student & Settings;

const settings: StudentsSettings = {
    id: '1',
    name: 'Andy',
    theme: 'dark',
    isLocalTimezone: true
}
Enter fullscreen mode Exit fullscreen mode

In the above example, we define three distinct types: Student, Settings, and StudentsSettings. The Student type represents basic student information such as name and id, while the Settings type encapsulates application settings for this student like theme and local timezone if preferable. Utilizing the intersection operator (&), we combine these types to create the StudentsSettings type.

As demonstrated, the StudentsSettings type inherits properties from both Student and Settings, enabling the creation of an object of this type that incorporates properties from both constituent types. This facilitates the representation of an employee object enriched with extended address information. Simple, right?

How we can use it?

Theoretically, we can use intersections for extending existing types. Suppose you have a foundational type that encompasses a common set of properties, and you need to create specialized types that inherit those properties while introducing additional ones. Everything will be clear and effortless, right?

Image description

The answer is not that easy!

Pitfalls of the intersections!

First of all, let's define what will really happen when we're trying to intersect some types. Let's start with primitive types:

type A = string & number;

const a: A = 1; // Type 'number' is not assignable to type 'never'.
Enter fullscreen mode Exit fullscreen mode

We get never, which indicates the values that will never occur. I understood it in the beginning that string and number have no intersections. Initially I had this picture in my head:

Intersection string and number how I saw it in my imagination

Continuing our exploration into the realm of TypeScript intersections, let's delve deeper by examining two additional examples:

type B = { name: string } & { name: number };
type C = { name: string } & { name: string | number  };

const b: B = { name: '1' } // Type 'string' is not assignable to type 'never'.
const c: C = { name: '1' };
Enter fullscreen mode Exit fullscreen mode

In the first example, we define the type B as the intersection of { name: string } and { name: number }. However, when we attempt to assign a value to b, we encounter an error: "Type 'string' is not assignable to type 'never'." This occurs because the intersection between a string and number results in an empty set of possible values, represented by the never type.

In the second example, for type C the resulting intersection is simply { name: string }. Since both constituents already contain string as a possible value for the name property, the assignment of { name: '1' } to c is valid.

We have only one problem with this understanding - it's misleading because intersections don't work this way all the time.

Rethinking sets theory

The official documentation says us:

Intersection types are closely related to union types, but they are used very differently. An intersection type combines multiple types into one.

This is exactly what we had in the first example, but it works till the moment you have the collision of the types and they are overriding each other. When the collision happens TS will decide who will survive.

type D = { name: string } & { name: string | number; id: number  };

const d: D = { name: '1', id: 1 };
Enter fullscreen mode Exit fullscreen mode

This case will pass the check and we predicted what we get as a result, but we used the wrong logic there. The official documentation doesn't help a lot with this understanding, because I see no answer to the question: "How TS combines types in such cases?"

We can make even more unclear examples:

type E = { name: string } & Record<string, number>;
type F = { name: string } & Record<string, 'foo' | 'bar'>;

const e: E = { name: '1', id: 1 } // Type '{ name: string; id: number; }' is not assignable to type 'E'.
const f: F = { name: 'foo', id: 'bar' } 
/*
Type '{ name: string; id: "bar"; }' is not assignable to type 'F'.
  Type '{ name: string; id: "bar"; }' is not assignable to type 'Record<string, "foo" | "bar">'.
    Property 'name' is incompatible with index signature.
      Type 'string' is not assignable to type '"foo" | "bar"'.
*/
Enter fullscreen mode Exit fullscreen mode

In the first example, we define the type E and e variable. However, when we attempt to assign the object { name: '1', id: 1 } to the variable e, a type error occurs "Type '{ name: string; id: number; }' is not assignable to type 'E'." Unlike the previous examples, we do not encounter the never type. This behavior may seem a bit peculiar, considering the previous illustrations.

Moving on to the second example, we define the type F as the intersection of { name: string } and Record<string, 'foo' | 'bar'>. At first glance, it appears to work as expected. However, there is an error that arises, leading to confusion. The expectation is that having either 'foo' or 'bar' should be acceptable from the perspective of the { name: string } type. However, the presented type creates a misleading impression of possible data assignments, which is incorrect. I found no way to create proper data for this type. So this type is equal to the first one when creating proper data is not possible.

Conclusion

These examples demonstrate that the behavior of intersections can become increasingly intricate. It one more time underscores the importance of thoroughly understanding TypeScript intersections and using them in proper cases, not as a universal tool for making types in the app.
During writing this post I found several questions on StackOverflow, where people expressed confusion regarding the behavior of the intersections' behavior and had the same wrong assumptions that I had. This observation indicates that I'm not the only one who was confused by TS intersections
Consequently, this realization has led me to utilize intersections in exceptional scenarios where alternative options are limited and I clearly know what I'm doing.

Thank you for reading. Any feedback is welcomed :)

Top comments (0)