The type hierarchy tree

#typescript

A 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.

alt
You are probably familiar with inheritance, one of the well-known concepts in object-oriented programming. Inheritance establishes an is-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}.

alt

two ways of checking assignability/substitutability#

One last thing before we dive into the type hierarchy tree in TypeScript:

  1. 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.
  2. 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.

alt

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.

alt

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.

alt

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 a string 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 
      

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)

alt

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

I specifically said in TypeScript because void is actually an operator in javascript - void in JavaScript evaluates the expression next to undefined as in 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 JS works with functions. In JavaScript it is pretty common when we reuse functions in different situations with the return values being ignored.

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:

  1. 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; }'

  1. 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'.

Further Reading#