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
/* | ... */;
Only the value 20
can be assigned to a variable of type 20
, only the value "Hello"
can be assigned to a variable of type "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
:
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.
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:
This usually happens when dealing with union types. For example the type "green" | "orange"
and the type "orange" | "red"
partially overlap!
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'
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
:
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:
// 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:
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.
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
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
.
Intersections
Intersections are just the opposite of unions: A & B
is the type of all values that simultaneously belong to A
and to B
:
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
:
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?
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";
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:
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:
- In our type-level programs, types are just data.
- There are 5 main categories of types: primitives, literals, data structures, unions, and intersections.
- Types are sets. Once you wrap your head around this concept, everything starts to make sense!
- Union types are data structures that join sets together.
unknown
is the final superset — it contains every other type.never
is the empty set — it is contained in every other type.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.
👍 Great job!
Footnotes
-
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. ↩
-
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!
Enroll in Type-Level TypeScript!
Get access to all chapters of the Type-Level TypeScript course and join a community of 1600+ students!
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...