The type hierarchy tree
#typescriptA reflection on my mental model of TypeScript’s type system
Try read the following TypeScript code snippet and work it out in your head to predicate whether or not there would be any type errors for each assignment:
// 1. any and unknown
let stringVariable: string = 'string'
let anyVariable: any
let unknownVariable: unknown
anyVariable = stringVariable
unknownVariable = stringVariable
stringVariable = anyVariable
stringVariable = unknownVariable
// 2. `never`
let stringVariable: string = 'string'
let anyVariable: any
let neverVariable: never
neverVariable = stringVariable
neverVariable = anyVariable
anyVariable = neverVariable
stringVariable = neverVariable
// 3. `void` pt. 1
let undefinedVariable: undefined
let voidVariable: void
let unknownVariable: unknown
voidVariable = undefinedVariable
undefinedVariable = voidVariable
voidVariable = unknownVariable
// 4. `void` pt. 2
function fn(cb: () => void): void {
return cb()
}
fn(() => 'string')
If you were able to come up with the correct answers without pasting the code into your editor and let the compiler does its job, I am genuinely going to be impressed. At least I couldn’t get them all right despite writing TypeScript for more than a year. I was really confused by this part of TypeScript which involves types like any
, unknown
, void
and never
I realized I didn’t have the correct mental model for how those types works. Without a consistent and accurate mental model, I could only rely on my experience or intuitions or constant trial and error from playing with the TypeScript compiler.
The blog post is my attempt to introspect and rebuild the mental model of TypeScript’s type system.
A warning up front: this is not a short article. You can jump directly to the section where I explore the type hierarchy tree if you are in a hurry.
It is a hierarchy tree#
Turns out all types in TypeScript take their place in a hierarchy. You can visualize it as a tree-like structure. Minimally, in a tree, we can a parent node and a child node. In a type system, for such a relationship, we call the parent node a supertype and the child node a subtype.
You are probably familiar with inheritance, one of the well-known concepts in object-oriented programming. Inheritance establishes anis-a
relationship between a child class and a parent class. If our parent class is Vehicle
, and our child class is Car
, the relationship is “Car
is Vehicle
”. However it doesn’t work the other way around - an instance of the child class logically is not an instance of the parent class. “Vehicle
is not Car
”. This is the semantic meaning of inheritance, and it also applies to the type hierarchy in TypeScript.
According to the Liskov substitution principle, instances of Vehicle
(supertype) should be substitutable with instances of its child class (subtype) Cars
without altering the correctness of the program. In other words, If we expect a certain behavior from a type (Vehicle
), its subtypes (Car
) should honor it.
I should mention that the Liskov substitution principle is from a 30-year-old paper written for PhD's. There are a ton of nuances to it that I cannot possibly cover in one blog post.
Putting this together, in TypeScript, you can assign/substitute an instance of a type’s subtype to/with an instance of that (super)type, but not the other way around.
By the way I just realize the meaning of the word “substitute” changes radically depending on the preposition that follows it. In this blog post, when I say "substitute A with B”, it means we end up with B instead of A.
nominal and structural typing#
There are two ways in which supertype/subtype relationships are enforced. The first one, which most mainstream statically-typed languages (such as Java) use, is called nominal typing, where we need to explicitly declare a type is the subtype of another type via syntax like class Foo extends Bar
. The second one, which TypeScript uses is structural typing, which doesn’t require us to state the relationship explicitly in the code. An instance of Foo
type is a subtype of Bar
as long as it has all the members that Bar
type has, even if Foo
has some additional members.
Another way to think about this supertype-subtype relationship is to check which type is more strict, type {name: string, age: number}
is more strict than the type {name: string}
since the former requires more members defined in its instances. Therefore type {name: string, age: number}
is a subtype of type {name: string}
.
two ways of checking assignability/substitutability#
One last thing before we dive into the type hierarchy tree in TypeScript:
- type cast: you can just assign a variable of one type to a variable of another type to see if it raises a type error. More on that later.
- the
extends
keyword -you can extend one type to another:type A = string extends unknown? true : false; // true type B = unknown extends string? true : false; // false
the top of the tree#
Let's talk about the type hierarchy tree.
In TypeScript, there are two types are that the supertypes of all other types: any
and unknown
.
They accept any value of any type, encompassing all other types.
This graph is by no means an exhaustive list of all the types that TypeScript has. Check out the source code of TypeScript if you are interested to see all the types that it currently supports.
upcast & downcast#
There are two types of type cast - upcast and downcast.
Assigning a subtype to its supertype is called upcast. By the Liskov substitution principle, upcast is safe so the compiler lets you do it implicitly, no questions asked.
There are exceptions where TypeScript disallows the implicit upcast. I will address that at the end of the post.
You can think of upcast similiar to walking up the tree - replacing (sub)types that are more strict with their supertypes that are more generic.
For example, every string
type is a subtype of the any
type and the unknown
type. That means the following assignments are allowed:
let string: string = 'foo'
let any: any = string // ✅ ⬆️upcast
let unknown: unknown = string // ✅ ⬆️upcast
The opposite is called downcast. Think of it as walking down the tree - replacing the (super)type that are more generic with their subtypes that are more strict.
Unlike upcast, downcast is not safe and most strongly typed languages don’t allow this automatically. As an example, assigning variables of the any
and unknown
type to the string
type is downcast:
let any: any
let unknown: unknown
let stringA: string = any // ✅ ⬇️downcast - it is allowed because `any` is different..
let stringB: string = unknown // ❌ ⬇️downcast
When we assign unknown
to a string
type, the TypeScript complier gives us a type error, which is expected since it is downcast so it cannot be performed without explicitly bypassing the type checker.
However TypeScript would happily allow us to assign any
to a string
type, which seems contradictory to our theory.
The exception here with any
is because, in TypeScript the any
type exists to act as a backdoor to escape to the JavaScript world. It reflects JavaScript’s overarching flexibility. Typescript is a compromise. This exception exists not due to some failure in design but the nature of not being the actual runtime language as the runtime language here is still JavaScript.
the bottom of the tree#
The never
type is the bottom for the tree, from which no further branches extend.
Symmetrically, the never
type behaves like the an anti-type of the top types - any
and unknow
, whereas any
and unknown
accept all values, never
doesn’t accept any value (including values of the any
type) at all since it is the subtype of all types.
let any: any
let number: number = 5
let never: never = any // ❌ ⬇️downcast
never = number // ❌ ⬇️downcast
number = never // ✅ ⬆️upcast
If you think hard enough, you might have realized that never
should have an infinite amount of types and members, as it must be assignable or substitutable to its supertypes, i.e. every other type in the type system in TypeScript according to the Liskov substitution principle. For example, our program should behave correctly after we substitute number
and string
with never
since never
is the subtype of both string
and number
types and it shouldn’t break the behavior defined by its supertypes.
Technically this is impossible to achieve. Instead, TypeScript makes never
an empty type (a.k.a an uninhabitable type): a type for which we cannot have an actual value at runtime, nor can we do anything with the type e.g. accessing properties on its instances. The canonical usecase for never
is when we want to type a return value from a function that never returns.
A function might not return for several reasons: it might throw an exception on all code paths, it might loop forever because it has the code that we want to run continuously until the whole system is shut down, like the event loop. All these scenarios are valid.
function fnThatNeverReturns(): never {
throw 'It never returns'
}
const number: number = fnThatNeverReturns() // ✅ ⬆️upcast
The assignment above might seem wrong to you at first - if never
is an empty type, why is that we can assign it to a number
type? The reason why such an assignment is fine is that the compiler knows that our function never returns so nothing will ever be assigned to the number
variable. Types exist to ensure that the data is correct at runtime. If the assignment never actually happens at runtime, and the compiler knows that for sure in advance, then the types don’t matter.
There is another way to produce a never
type is to intersect two types that aren’t compatible - e.g. {x: number} & {x: string}
.
type Foo = {
name: string,
age: number
}
type Bar = {
name: number,
age: number
}
type Baz = Foo & Bar
const a: Baz = {age: 12, name:'foo'} // ❌ Type 'string' is not assignable to type 'never'
Edit from the future: I realized that there are some nuances to the resulting type - if disjoint properties are considered as discriminant properties (roughly, those whose values are of literal types or unions of literal types), the whole type is reduced to
never
. This is a feature introduced in TypeScript 3.9. Check out this PR for details and motivation.
types in between#
We have talked about the top types and the bottom type. The types in between are just the other regular types you use everyday - number
, string
, boolean
, composite types like object
etc.
There shouldn’t be too much surprise as to how those types work once we have established the correct mental model:
- it is allowed to assign a string literal type e.g.
let stringLiteral: 'hello' = 'hello'
to astring
type (upcast) but not the other way around (downcast) - it is allowed to assign a variable holding an object of a type with extra properties to an object of a type with less properties when the existing properties’ types match (upcast) but not the other way around (downcast)
type UserWithEmail = {name: string, email: string} type UserWithoutEmail = {name: string} type A = UserWithEmail extends UserWithoutEmail ? true : false // true ✅ ⬆️upcast
- Or assign an non-empty object to an empty object:
const emptyObject: {} = {foo: 'bar'} // ✅ ⬆️upcast
- Or assign an non-empty object to an empty object:
However there is one type I want to talk more about in this section since people often confuse it with the bottom type never
and that type is void
.
In many other languages, such as C++, void
is used as the a function return type that means that function doesn't return. However, in TypeScript, for a function that doesn't return at all, the correct type of the return value is never
.
So what is the type void
in TypeScript? void
in TypeScript is a supertype of undefined
- TypeScript allows you to assign undefined
to void
(upcaset) but again, not the other way around (downcast)
This can also be verified via the extends
keyword:
type A = undefined extends void ? true : false; // true
type B = void extends undefined ? true : false; // false
void
is also an operator in javascript that evaluates the expression next to it toundefined
, e.g.void 2 === undefined // true
.
In TypeScript, the type void
is used to indicate that the implementer of a function is making no guarantees about the return type except that it won’t be useful to the callers. This opens the door for a void
function at runtime to return something other than undefined
, but whatever it returns shouldn’t be used by the caller.
function fn(cb: () => void): void {
return cb()
}
fn(() => 'string')
At first blush this might seem like a violation for the Liskov substitution principle since the type string
is not a subtype of void
so it shouldn’t be able to be substitutable for void
. However, if we view it from the perspective of whether or not it alters the correctness of the program, then it becomes apparent that as long as the caller function has no business with the returned value from the void
function (which is exactly the intended outcome of the void
type), it is pretty harmless to substitute that with a function that returns something different.
This is where TypeScript is trying to be pragmatic and complements the way JavaScript works with functions. In JavaScript it is pretty common when we reuse functions in different situations with the return values being ignored.
Another cool tip about void
type (credit to @simey) is that you can annotate this
with void
when declaring a function:
function doSomething(this: void, value: string) {
this // void
}
This prevents you from using this
inside the function.
situations where TypeScript disallows implicit upcast#
Generally there are two situations, and to be honest it should be pretty rare to find yourself in these situations:
- When we pass literal objects directly to function
function fn(obj: {name: string}) {}
fn({name: 'foo', key: 1}) // ❌ Object literal may only specify known properties, and 'key' does not exist in type '{ name: string; }'
- When we assign literal objects directly to variables with explicit types
type UserWithEmail = {name: string, email: string}
type UserWithoutEmail = {name: string}
let userB: UserWithoutEmail = {name: 'foo', email: 'foo@gmail.com'} // ❌ Type '{ name: string; email: string; }' is not assignable to type 'UserWithoutEmail'.