At its base, intersection in Typescript is a very easy concept. It combines multiple types into one, right? Well, not -really- though. Let’s find out why.
Reminder: what’s an intersection again?
We take two types and try to “combine” them with an &:
type Person = { name: string };
type Student = { grade: number };
type Combined = Person & Student; // { name: string; grade: number; }
The name intersection comes from set theory
Intersection comes from set theory in math. It takes shared members between sets and creates a new set. That new set is called the intersection.
type Hero = { hero: 'Clark Kent' };
type Villain = { villain: 'Lex Luthor' };
type Smallville = Hero & Villain;
How does intersection work in Typescript?
This results in a type that describes a value that can be used as follows:
{ hero: "Clark Kent", villain: "Lex Luthor" };
Unlike the intersection from the set theory example, Hero and Villain do not have shared members. They do not have keys that are available on both types. So how did it achieve its result? What did it intersect? Required properties? Not really. Try and think what the following examples would yield:
type Example1 = 'Clark Kent' & 'Bruce Wayne';
type Example2 = { hero: string; hero: 'Clark Kent' };
type Example3 = 23 & 'Michael Jordan';
type Example4 = { number: 23 } & { number: 8 };
type Example5 = boolean & true;
type Example6 = false & boolean & true;
type Example7 = ('Clark Kent' | 'Bruce Wayne') & ('Lex Luthor' | 'Joker');
Understanding structural typing
In order to understand what intersection is really doing and what it’s intersecting, we need to understand Typescript’s type system. Typescript has a structural type system, which means that types are determined by the structure of the data rather than by the name of the class or interface (which is the case with nominal typing). But this probably means absolutely nothing to you if you’re at this point.
To clarify: you can throw any value against a type, and as long as it adheres to the minimum requirements of the type, it’s valid.
I know, still abstract. Let’s look at an example:
type Movie = { title: string };
function printMovie(movie: Movie) {
return movie.title;
}
const movie = { title: 'The Dark Knight' };
const hero = printMovie(movie); // ✅
This makes sense. Nothing weird to see here. But let’s say we add an extra property to the anonymous object movie
:
const movie = { title: 'The Dark Knight', hero: 'Batman' };
const hero = printMovie(movie); // ✅
This is still valid. And the following works as well:
const movie = {
title: 'The Dark Knight',
hero: 'Batman',
hasGoodEnding: true,
imdbGrade: 9,
};
const hero = printMovie(movie); // ✅
movie
has the title
key which has a string
value, so it adheres to the Movie
type. It doesn’t care about the other properties.
Theres an exception In Typescript, for object literals, called “excess property checking“. We’ll discuss it at the end.
Thinking about types as sets of possible values
If we analyse the previous example we could say that when you create the following type:
type Movie = { title: string };
It describes that any object value can be assigned to it, as long as it has a key value pair of { title: string } at minimum. The following values (a set if you will) are all valid according to type Movie:
const movie = { title: "The Dark Knight" };
const movie = { title: "The Dark Knight", villain: "Joker"};
const movie = { title: "The Dark Knight", year: 2008, imdbUrl: "https://www.imdb.com/title/tt0468569/", hasGoodEnding: boolean; numberOfViewers: 13 };
As you may have deducted: the type Movie describes an infinite amount (a set) of object values that are assignable to the type Movie. We can add thousands of extra keys and it will still be valid.
A type describes a set of possible values
Intersection intersects on possible values that can be assigned to a type
Now that we know that a type describes a set of possible values, we can look at what intersection does. Let’s go back to our initial Smallville
type:
type Hero = { hero: 'Clark Kent' };
type Villain = { villain: 'Lex Luthor' };
type Smallville = Hero & Villain;
It’s an intersection that contains two types: Hero
and Villain
. Hero
describes an almost infinite amount of possible values that can be assigned to it:
const hero = { hero: "Clark Kent" };
const hero = { hero: "Clark Kent", age: 19};
const hero = { hero: "Clark Kent", isJournalist: true, movies: ["Man of Steel", "Superman 3"};
// etc, etc.
And it will also describe this possible value:
const hero = { hero: 'Clark Kent', villain: 'Lex Luthor' };
The same goes for Villain
:
const villain = { villain: 'Lex Luthor' };
const villain = { villain: 'Lex Luthor', age: 24 };
const villain = { villain: 'Lex Luthor', hair: false, friends: [], father: 'Lionel' };
// But it also describes this possible value:
const villain = { villain: 'lex luthor', hero: 'clark kent' };
And as you can see, it has found a shared possible value between both types:
{ villain: "Lex Luthor", hero: "Clark Kent" }
And that’s where it intersects. Hence the name intersection.
Applying the same principle on other types
Let’s intersect other types than objects and start with a string type.
type Hero = string;
The same goes here; you have to look at the type string as a set of possible values. In this case a set of an infinite amount of literal strings:
type Hero = string;
const printHero = (hero: Hero) => hero;
// These are all valid, because they belong to the set of the `string` type
const hero = 's';
const hero = 'spide';
const hero = 'batm';
const hero = 'aquaman';
// etc, etc
Every string (literal) you can imagine is a part of the possible values within the string set.
Let’s create a string literal type:
type Villain = 'Lex Luthor';
We can look at the Villain
type, again, as a set of possible values. But now the only possible value here is ‘Lex Luthor’. It’s a set of one:
const printVillain = (villain: Villain) => villain;
// The only possible value that can be assigned to Villain:
const villain = 'Lex Luthor';
We can’t assign something else than “Lex Luthor” to Villain because we’ve explicitly described that the set of possible values we’re allowing is just one string literal: “Lex Luthor”.
Let’s intersect:
type Hero = string;
type Villain = 'Lex Luthor';
type MovieIntersection = Hero & Villain;
As we saw before, it tries to intersect on possible values described by all types in the intersection. Since Villain
is a set of one: ‘Lex Luthor’, and Hero
is a set of all strings possible, it will intersect on the ‘Lex Luthor’ string literal.
type Hero = string;
type Villain = 'Lex Luthor';
type MovieIntersection = Hero & Villain; // "Lex Luthor"
Empty intersections (set) will result in a never
type
You’ve probably seen the never
type around. It represents an empty set of possible values. If an intersection can not find a shared possible value between all passed types, it will result in an empty set: never
.
type Hero = 'Clark Kent';
type Villain = 'Lex Luthor';
type Characters = Hero & Villain; // never
type MinAge = 23;
type MaxAge = 50;
type Ages = MinAge & MaxAge; // never
There is no overlapping possible value that can be assigned to both Hero and Villain so the middle part stays empty and the intersection is of type never
.
The same goes for objects. For example; two sets that have the same key with a string literal will never have a matching possible value.
type Hero = { name: 'Clark Kent' };
type Villain = { name: 'Lex Luthor' };
type Characters = Hero & Villain; // never
Typescript’s own little exception that could confuse you: excess property checking
We’ve gone over the theory and as to why things are called as they are and how they work. But there’s one important thing to note. Typescript made a very specific deviation from standard implementation of structural typing: annotated object literals will be checked for excess properties.
To illustrate; this is our earlier example:
type Movie = { title: string };
const printMovie = (movie: Movie) => movie.title;
And when we created an anonymous object to pass to printMovie, everything worked as expected:
const movie = { title: "The Dark Knight", hero: "Batman" };
const hero = printMovie(movie); ✅
But when we try to insert an object literal:
const hero = printMovie({
title: "The Dark Knight",
hero: "Batman"
}); ❌
Typescript will give us an error:
❌ Object literal may only specify known properties, and ‘hero’ does not exist in type ‘Movie’.(2353)
And that’s because Typescript has a built-in excess property check for object literals. It assumes you have made an error when explicitly passing an object literal as a value that doesn’t exactly adhere to the Movie type.
It’s the same reason why this will fail as well:
type Movie = { title: string };
const movie: Movie = { title: 'Superman', villain: 'Lex Luthor' };
❌ Object literal may only specify known properties, and ‘villain’ does not exist in type ‘Movie’.(2353)
Closing notes
Understanding Typescript’s type system is, I think, the key to mastering the language. Although we have focused on the intersections, you can apply the same learned principles on other parts of Typescript as well.
Any questions? Let me know!