The type hierarchy tree
Tags: typescript
Published: 2022-02-06 19:52:03

---
title: The type hierarchy tree
published: true
listed: true
date: '12/28/2021'
description: A reflection on my mental model of TypeScript’s type system
tags: typescript
canonical_url: https://www.zhenghao.io/posts/type-hierarchy-tree
---

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:

```typescript
// 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](#the-top-of-the-tree) 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](https://www.zhenghao.io/art/blog/type-hierarchy-tree/supertype-subtype.png)
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](https://en.wikipedia.org/wiki/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](https://www.blog.voicetube.com/archives/55539).  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](https://www.zhenghao.io/art/blog/type-hierarchy-tree/supertype-subtype2.png)


## 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](#upcast--downcast).
2. the `extends` keyword -you can extend one type to another: 
    ```typescript
    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](https://www.zhenghao.io/art/blog/type-hierarchy-tree/top.png)

> This graph is by no means an exhaustive list of all the types that TypeScript has. Check out [the source code](https://github.com/microsoft/TypeScript/blob/main/src/compiler/types.ts#L642) 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](https://www.zhenghao.io/art/blog/type-hierarchy-tree/cast.png)

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](#situations-where-typescript-disallows-implicit-upcast).


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:

```typescript
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:

```typescript
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](https://www.zhenghao.io/art/blog/type-hierarchy-tree/bottom.png)

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. 

```typescript
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.

```typescript
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}`.




```tsx
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](https://github.com/microsoft/TypeScript/pull/36696) 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)
    ```typescript
    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:
        ```typescript
            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++](https://docs.microsoft.com/en-us/cpp/cpp/void-cpp?view=msvc-170), `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](https://www.zhenghao.io/art/blog/type-hierarchy-tree/void.png)

This can also be verified via the `extends` keyword:
```typescript
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 to `undefined`, 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.

```typescript
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

```typescript
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; }'

```

2. When we assign literal objects directly to variables with explicit types

```typescript
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
- [Assignability Matrix in the TypeScript official docs](https://www.typescriptlang.org/docs/handbook/type-compatibility.html#any-unknown-object-void-undefined-null-and-never-assignability)