Chapters

  • 0. Introduction

    Free

  • 1. Types & Values

    Free

  • 2. Types are just data

    Free

  • 3. Objects & Records

    Free

  • 4. Arrays & Tuples

    Free

  • 5. Conditional Types
  • 6. Loops with Recursive Types
  • 7. Template Literal Types
  • 8. The Union Type Multiverse
  • 9. Loops with Mapped Types
  • 10. Assignability In Depth

    wip

  • 11. Debugging Types

    wip

Articles

Subscribe

About

Made with ❤️ by @GabrielVergnaud

Types are just data

Every programming language is about transforming data and Type-level TypeScript is no exception. The main difference with other programming languages is that types are our data! We will write programs that take types as input and output some other types.

Mastering this language will require a solid understanding of the specificities of its different kinds of data and data structures. In the next few chapters, we'll get to know them and see both how they relate to the concepts we use at the value level and how they differ from them. Let's get started!

Five categories of types

TypeScript provides us with 5 main categories of types: primitive types, literal types, data structure types, union types, and intersection types.

Primitive types

You are certainly already very familiar with primitive types. We use them all the time to annotate variables and functions in our day-to-day TypeScript code. Here is the list of primitive types:

type Primitives =
  | number
  | string
  | boolean
  | symbol
  | bigint
  | undefined
  | null;

Every JavaScript value except objects and functions belongs to this category. Some primitive types are the home of an infinite number of values, like number or string, but two of them only contain a single lonely inhabitant: null and undefined. This specificity makes them also part of our 2nd category: literal types.

Literal types

Literal types are "exact" types, which encompass a single possible value.

type Literals =
  | 20
  | "Hello"
  | true
  | 10000n
  /* | ... */;

A variable of type 20 can only be assigned to the value 20, a variable of type "Hello" can only be assigned to the value "Hello", etc.

const twenty: 20 = 20; // ✅ works!
const hundred: 20 = 100;
//          ^ ❌ `100` isn't assignable to the type `20`.

There's an infinite number of literal types and they look just like regular values, but make no mistake: those are types!

Values and types belong to two different worlds — they exist separately and can't be mixed together in a single expression1. I find it helpful to see literal types as a sort of reflection of values in the world of types, but we need to keep in mind that they are different things. One notable difference is that we can't write arithmetic expressions at the type level. For instance type five = 2 + 3 won't work, even if const five = 2 + 3 is perfectly valid.

Literal types become particularly useful when put in unions to describe variables that only contain a finite set of possible values like type TrafficLight = "green" | "orange" | "red".

Data structures

In our type-level world, we have four built-in data structures at our disposal: objects, records, tuples and arrays.

type DataStructures =
  | { key1: boolean; key2: number } // objects
  | { [key: string]: number } // records
  | [boolean, number] // tuples
  | number[]; // arrays
  • Object types describe objects with a finite set of keys, and these keys contain values of potentially different types.
  • Record types are similar to object types, except they describe objects with an unknown number of keys, and all values in a record share the same type. For example, in { [key: string]: number }, all values are numbers.
  • Tuple types describe arrays with a fixed length. They can have a different type for each index.
  • Array types describe arrays with an unknown length. Just like with records, all values share the same type.

We will spend more time playing with them in upcoming chapters.

Unions and Intersections

Everything we have seen so far looks somewhat similar to concepts we are used to at the value level, but unions and intersections are different. They are really specific to the type level, and building a good mental model of how they work is essential, although a little more challenging.

Here is how they look:

type Union = X | Y;

type Intersection = X & Y;

You can read the union X | Y as “either a value of type X or of type Y” and the intersection X & Y as “a value that is simultaneously of type X and Y”.

We tend to think of | and & as operators, but in fact, they are data structures too.

Creating the union X | Y doesn't turn X and Y into a new opaque type the way an operator would. Instead, it puts X and Y in a sort of box, from which we can extract them later. In upcoming chapters, we will see that we can even loop through each type in a union. With that in mind, | looks more like a way to add types to some kind of "union" data structure. But what is it exactly?

Well, we could say that unions are the type-level equivalent to JavaScript's Sets, but the full story is a little more complex. To get a better grasp of unions and intersections types, I'll need to cover a notion that's fundamental to Type-level TypeScript: all types are sets.

Types are Sets

An interesting feature of TypeScript is that a value can belong to more than one type. For example, the value 2 can be assigned to a variable of type number, but also to a variable of type 2, or even of type 1 | 2 | 3. This property is called subtyping. It means that types can be included in other types, or, in other words, that types can be subsets of other types.

This implies that not only unions, but all types are sets! Types can contain other types, overlap with each other, or be mutually exclusive.

For example, the literal type "Hi" and the literal type "Hello" are both included in the type string because each of them is part of the greater family of all strings:

The string set contains the literal types 'Hi' and 'Hello'.

We say that "Hi" and "Hello" are subtypes of string and that string is their supertype. This means you can assign variables of type "Hi" or "Hello" to variables of type string, but not the other way around:

let hi: "Hi" = "Hi";
let hello: "Hello" = "Hello";

let greeting: string;

greeting = hi; // ✅ type-checks!
greeting = hello; // ✅ type-checks!

hello = greeting; // ❌ doesn't type-check!

We can also say that "Hi" and "Hello" are assignable to the type string.

The concept of assignability is omnipresent in TypeScript. Most type errors will tell you that some type isn't assignable to some other type. When you start thinking about types as sets of values, assignability becomes much more intuitive — “A is assignable to B” just means “the set B includes all values within the set A”, or “A is a subset of B”.

The type string and the type number are mutually exclusive: they don't overlap because no value can belong to both sets at the same time.

The string and the number sets are mutually exclusive.

That's why you can't assign a variable of type string to a variable of type number and vice versa:

let greeting: string = "Hello";
let age: number = greeting; // ❌ doesn't type-check.

Lastly, two types can sometimes partially overlap with each other. In this case, they are neither mutually exclusive nor have a subtyping relationship:

Types A and B are overlapping.

This usually happens when dealing with union types. For example the type "green" | "orange" and the type "orange" | "red" partially overlap!

Two overlapping union types.

Let's give names to these 2 union types:

type CanCross = "green" | "orange";
type ShouldStop = "orange" | "red";

Now, can we assign a variable of type CanCross to one of type ShouldStop?

let canCross = "orange" as CanCross; // ✅
let shouldStop = "orange" as ShouldStop; // ✅
canCross = shouldStop;
//       ❌ ~~~~~~~~~ type 'red' isn't assignable to the type `green` | 'orange'
shouldStop = canCross;
//         ❌ ~~~~~~~ type 'green' isn't assignable to the type `orange` | 'red'

Nope. Even if CanCross and ShouldStop both include the literal type "orange", we can't assign a variable of one type to the other because they don't overlap completely.

In this example, we can clearly see that both canCross and shouldStop contain the value "orange" so it may be counterintuitive that you can't assign one to the other. Remember that TypeScript doesn't know what value a variable contains. It only knows about its type!

Unions join sets together

If you know a bit of Set Theory, you know that the union of two sets is the set containing those 2 sets, so A | B is the type containing all values of type A and all values of type B:

The union of two sets.

We can join any 2 sets together, including other union types! For example, we can join CanCross and ShouldStop from the previous example into a TrafficLight union type:

The union of CanCross and ShouldStop.
// this is equivalent to "green" | "orange" | "red"
type TrafficLight = CanCross | ShouldStop;

let canCross: CanCross = "green";
let shouldStop: ShouldStop = "red";

let trafficLight: TrafficLight;
trafficLight = shouldStop; // ✅
trafficLight = canCross; // ✅

TrafficLight is a superset of CanCross and of ShouldStop. Notice that "orange" is there only once in TrafficLight. This is because sets can't contain duplicates so neither can union types.

Union types contribute to the creation of a hierarchy of nested sets within which every type belongs. Since we can always put 2 types in a union, we can create an arbitrary number of subtyping levels:

The subtyping hierarchy

At this point, you may be wondering, if all types can belong to other types, when does this hierarchy of nested types stop? Is there a type that's the final boss and contains every other possible type in the universe?

Well, there is one such type and its name is unknown.

unknown — the top of the subtyping hierarchy

unknown contains each and every type you will ever use in TypeScript.

the unknown type contains every other types

You can assign anything to a variable of type unknown:

let something: unknown;

something = "Hello";            // ✅
something = 2;                  // ✅
something = { name: "Alice" };  // ✅
something = () => "?";          // ✅

This is nice, but it also means you can't do much with a variable of type unknown because TypeScript doesn't have a clue about the value it contains!

let something: unknown;

something = "Hello";
something.toUpperCase();
//       ^ ❌ Property 'toUpperCase' does not exist
//            on type 'unknown'.

The union of any type A with unknown will always evaluate to unknown. That makes sense because, by definition, A is already contained in unknown:

A | unknown = unknown
Any type A is contained in unknown.

But what about intersections?

The intersection of any type A with unknown is the type A:

A & unknown = A

That's because intersecting a set A with a set B means extracting the part of A that also belongs to B! Since any type A is inside unknown, A & unknown is just A.

Intersecting with unknown is a no-op.

Intersections

Intersections are just the opposite of unions: A & B is the type of all values that simultaneously belong to A and to B:

intersection of sets

Intersections are particularly convenient with object types because the intersection of 2 objects A and B is the set of objects that have all properties of A and all properties of B:

intersection of objects

That's why we sometimes use intersections to merge object types together 2:

type WithName = { name: string };
type WithAge = { age: number };

function someFunction(input: WithName & WithAge) {
  // `input` is both a `WithName` and a `WithAge`!
  input.name; // ✅ property `name` has type `string`
  input.age; // ✅ property `age` has type `number`
}

But what happens if we try to intersect two types that do not overlap at all? What does it even mean to intersect string and number for instance?

Diagram representing the string type and number type.

It may look like string & number would get us some kind of type error, but actually, this makes sense to the type-checker! The result of intersecting types that do not overlap is the empty set. A set that does not contain anything.

In TypeScript, the empty set is called never.

never — the empty set

The type never doesn't contain any value, so we can use it to represent values that should never exist at runtime. For instance, a function that always throws will return a value of type never:

function panic(): never {
  throw new Error("🙀");
}

const oops: never = panic(); // ✅

That's because the code using oops can never be reached!

That's good to know, but never doesn't sound very useful in practice, don't you think?

You would be surprised! We use never all the time when writing type-level code. never is essentially an empty union type. Having an empty type is immensely useful for type-level logic. We will use it to remove keys from object types, filter out items from a union, represent impossible cases, etc.

An interesting property of never is that it's a subtype of every other types — it's at the very bottom of our hierarchy of sets. That means you can assign a value of type never to any other type:

const username: string = panic(); // ✅ TypeScript is ok with this!
const age: number = panic(); // ✅ And with this.
const theUniverse: unknown = panic(); // ✅ Actually, this will always work.

If you put never in a union with an existing union type, it leaves it unchanged:

type U = "Hi" | "Hello" | never;
// is equivalent to:
type U = "Hi" | "Hello";
Diagram representing the never type inside a union of string literals.

It's just like merging an empty set with another set. The union of any type A with never is equal to A:

A | never = A

If you intersect a type A with never however, you will always get back never:

A & never = never

Makes sense! Nothing intersects with an empty set.

What about any?

You may have noticed that I have been omitting any from all the examples I've given so far. Why?

Everyone knows that using any is considered a bad practice in TypeScript. It's used as an escape hatch to bypass type-checking. We sometimes use it anyway, maybe because we don't know how to properly type a piece of code, or because we don't have the time necessary to find the right way to fix a type error. But where does any fit in our subtyping mental model, and why does it bypass type-checking anyway?

To be honest, any doesn't fit well in our mental model because it doesn't respect the laws of Set Theory. any is both the supertype and the subtype of every other TypeScript type. any is all over the place:

the any type in the subtyping hierarchy

I know, right? 🤷

In addition to not making much sense, any has the tendency to spread in a codebase because as soon as you use it in a type expression, it evaluates to any:

A | any = any
A & any = any

any is a weird one. We won't use it much at the type level, except in a few special places where it doesn't hurt, like type constraints, or on the right-hand side of conditional types, but let's leave that aside for now.

Summary

What a chapter! We have already covered some of the most important concepts needed to become a TypeScript expert. Let me summarize what we've learned so far:

  1. In our type-level programs, types are just data.
  2. There are 5 main categories of types: primitives, literals, data structures, unions, and intersections.
  3. Types are sets. Once you wrap your head around this concept, everything starts to make sense!
  4. Union types are data structures that join sets together.
  5. unknown is the final superset — it contains every other type.
  6. never is the empty set — it is contained in every other type.
  7. any is weird because it's the subset and the superset of every type.

Challenges!

Let's practice! Check out the How Challenges Work section from the previous chapter if you haven't already, to learn how to take these challenges.

Challenge
Solution
Challenge
Solution
Challenge
Solution
Challenge
Solution
Challenge
Solution
Challenge
Solution

👍 Great job!

Footnotes

  1. This is true in TypeScript, but some languages do allow mixing values and types together in the same expression and the line between the world of values and the world of types becomes blurrier. They are called "dependent" type systems. ↩

  2. Intersecting two object types is not exactly like merging two objects with {...a, ...b}, the way we would in JavaScript, because the intersection is applied recursively on keys that exist on both object types. We will learn more about intersections of objects in the next chapter. ↩

If you enjoyed this free chapter of Type-Level TypeScript, consider supporting it by becoming a member!

Support Type-Level TypeScript!

Become an Early-Bird Member to support the writing of Type-Level TypeScript and get access to all upcoming chapters!

You will find everything you need to become a TypeScript Expert — 11 chapters of in-depth, unique content, and more than 70 fun challenges to practice your new skills.

  • Full access to all 11 chapters

  • 70 type challenges with explanations

  • Lifetime access to all course materials

  • Exclusive discord community

Loading...

⟸ Previous

1. Types & Values

Next ⟹

3. Objects & Records