DEV Community

Cover image for Type system hierarchy in TypeScript: from Top Type to Bottom Type
Linbudu
Linbudu

Posted on

Type system hierarchy in TypeScript: from Top Type to Bottom Type

Hi everyone, this is my first post in the DEV community, and it's actually from a section of an ebook I published in mainland China, and it's one of the parts that I think is very important for all TypeScript learners: the type system hierarchy. I haven't found many introductory articles on it in the community, but it's really an important theoretical foundation for conditional types and type programming, and it's also central to understanding the type system as a whole.

Also, I relied on the translation tool DeepL for the vast majority of this article, which I think is the best translation tool available, but probably still not to the extent of a skilled language user, so I'm sorry it may not be a good reading experience for you.

If the type system is an important basic knowledge in TypeScript, then the type hierarchy is one of the most important concepts in the type system. For developers who have no experience with other typed languages, it is not too much to say that type hierarchy is the most important basic concept.

On the one hand, type hierarchy helps us to clarify the hierarchy and compatibility of types, which is often the cause of many type errors. On the other hand, type hierarchy is an essential pre-requisite for the subsequent learning of conditional types.

Type hierarchy actually refers to the compatibility of all types in TypeScript, from the top level of any type to the bottom level of never type. So, what does the top-to-bottom type compatibility relationship look like? In this article, we'll start by comparing primitive type variables and literal types, and extend them up and down, respectively, to form a hierarchical chain that allows you to build the entire type system of TypeScript.

All code samples can be found in Type Hierarchy

The way to determine type compatibility

Before we start, we need to understand how to visually determine the compatibility of two types. In this section we will mainly use conditional types to determine type compatibility, something like this:

type Result = 'linbudu' extends string ? 1 : 2;
Enter fullscreen mode Exit fullscreen mode

If 1 is returned, then 'linbudu' is a subtype of string. Otherwise, it is not valid. Note, however, that not being true does not mean that string is a subtype of 'linbudu'.

There is an alternative way to perform compatibility checks by assignment, which is roughly used as follows:

declare let source: string;

declare let anyType: any;
declare let neverType: never;

anyType = source;

// Type 'string' is not assignable to type 'never'.
neverType = source;
Enter fullscreen mode Exit fullscreen mode

For variable a = variable b, if it holds, it means that <type of variable b> extends <type of variable a> holds, i.e. type b is a subtype of type a, in this case string extends never, which is obviously not true.

Don't think it's easy to understand? Then try to think of it this way: we have a variable of type "dog" and two other variables of types "corgi" and "orange cat" respectively.

  • Dog = Corgi, implying that it is okay to use Corgi as a dog.
  • Dog = British short cat, obviously not right, the program's use of the variable "dog", are based on it is a "dog" type.

There is no obvious difference between these two types, but only a slight difference in the usage scenario. When you need to judge the hierarchy of multiple types, conditional types are more intuitive, while if it is just a compatibility judgment between two types, using type declarations is a bit more understandable, you can choose according to your own habits.

Starting from the primitive type

Once we understand how type compatibility is determined, we can begin to explore the type hierarchy. First, we start with primitive types, object types (collectively referred to as base types later), and their corresponding literal types:

type Result1 = "linbudu" extends string ? 1 : 2; // 1
type Result2 = 1 extends number ? 1 : 2; // 1
type Result3 = true extends boolean ? 1 : 2; // 1
type Result4 = { name: string } extends object ? 1 : 2; // 1
type Result5 = { name: 'linbudu' } extends object ? 1 : 2; // 1
type Result6 = [] extends object ? 1 : 2; // 1
Enter fullscreen mode Exit fullscreen mode

Obviously, there must be a parent-child type relationship between a base type and their corresponding literal types. Strictly speaking, object does not appear here properly, because it actually represents all types that are not primitive types, i.e., arrays, objects, and function types, so Result6 holds here because the literal type [] can also be considered as a literal type of object. We abbreviate the conclusion as, Literal type < Corresponding primitive type.

Next, we explore the type hierarchy up and down from this primitive type and literal.

Explore upwards to the top of the dome

Union Types

In a union type, only one of the types needs to be met for us to consider the union type implemented, expressed in conditional types as follows:

type Result7 = 1 extends 1 | 2 | 3 ? 1 : 2; // 1
type Result8 = 'lin' extends 'lin' | 'bu' | 'du' ? 1 : 2; // 1
type Result9 = true extends true | false ? 1 : 2; // 1
Enter fullscreen mode Exit fullscreen mode

At this level, it is not necessary that all members of the union type are of literal type or that the literal type comes from the same base type as such a prerequisite, only that the type exists in the union type.

For primitive types, the comparison of union types is actually consistent:

type Result10 = string extends string | false | number ? 1 : 2; // 1
Enter fullscreen mode Exit fullscreen mode

Conclusion: *Literal type < Union type containing this literal type, Primitive type < Union type containing this primitive type. *

And if a union type consists of type literals of the same base type, then this time the situation is a bit different again. Since all your type members are of the string literal type, aren't you my string type's little brother? If all your type members are object, array literal, or function types, aren't you my object type's little brother?

type Result11 = 'lin' | 'bu' | 'budu' extends string ? 1 : 2; // 1
type Result12 = {} | (() => void) | [] extends object ? 1 : 2; // 1
Enter fullscreen mode Exit fullscreen mode

Conclusion: *A Literal union type of the same base type < This base type. *

Combining the conclusions and removing the more special cases, we get this final conclusion: Literal quantity type < Union type containing this literal type (same base type) < Corresponding primitive type, i.e.

// 2
type Result13 = 'linbudu' extends 'linbudu' | '599'
  ? 'linbudu' | '599' extends string
    ? 2
    : 1
  : 0;
Enter fullscreen mode Exit fullscreen mode

For such nested union types, we can just directly observe the result of the last conditional statement here, because if all conditional statements hold, the result is the value when the last conditional statement is true. Also, since union types are actually a rather special existence and most types have at least one union type as their parent, we will not embody union types later.

Now, our focus is on the base types, namely string and object.

Boxed Types

We know that the JavaScript boxed object String has its counterpart in TypeScript: the String type, and the Object object and Object type, which are proudly at the top of the prototype chain.

Obviously, the string type will be a subtype of the String type, and the String type will be a subtype of the Object type, so what else is in between? Well, there is, and you wouldn't have guessed it. Let's look directly at the type hierarchy from string to Object:

type Result14 = string extends String ? 1 : 2; // 1
type Result15 = String extends {} ? 1 : 2; // 1
type Result16 = {} extends object ? 1 : 2; // 1
type Result18 = object extends Object ? 1 : 2; // 1
Enter fullscreen mode Exit fullscreen mode

It looks like a strange thing to mix in here, isn't {} a literal type of object? Why can we compare it here, and why is String a subtype of it?

At this point I'm going to assume you already understand the difference between the structured type system and the nominal type system, and assume that we think of String as a normal object with some methods on it, such as below:

interface String {
  replace: // ...
  replaceAll: // ...
  startsWith: // ...
  endsWith: // ...
  includes: // ...
}
Enter fullscreen mode Exit fullscreen mode

At this point, can it be seen as String inheriting the empty object {} and implementing these methods itself? Of course you can! In comparison to the structured type system, String is considered a subtype of {}. Here it looks like a type chain is being constructed from string < {} < object, but in fact string extends object does not hold:

type Tmp = string extends object ? 1 : 2; // 2
Enter fullscreen mode Exit fullscreen mode

Because of this feature of the structured type system, we can draw some seemingly contradictory conclusions:

type Result16 = {} extends object ? 1 : 2; // 1
type Result18 = object extends {} ? 1 : 2; // 1

type Result17 = object extends Object ? 1 : 2; // 1
type Result20 = Object extends object ? 1 : 2; // 1

type Result19 = Object extends {} ? 1 : 2; // 1
type Result21 = {} extends Object ? 1 : 2; // 1
Enter fullscreen mode Exit fullscreen mode

Why do these two pairs, 16-18 and 19-21, hold no matter how you judge them? Does it mean that {} is the same type as object and the same type as Object?

Of course not, {} extends and extends {} are actually two completely different ways of comparing. {} extends object and {} extends Object mean that {} is a literal type of object and Object, starting from the level of type information, i.e. literal types provide more detailed type information on top of the base type. object extends {} and Object extends {}, on the other hand, start from the level of comparison of structured type systems, i.e., {}, as an empty object with nothing, can be seen as the base class of almost all types, the origin of everything. If you confuse these two ways of comparing types, you may get the wrong conclusion like string extends object.

The case of object extends Object and Object extends object is a bit more special, because they are "systematically set", Object contains all types except Top Type (base type, function type, etc.), object contains all types except Top Type. Object contains all types other than Top Type (base types, function types, etc.), while object contains all non-primitive types, i.e., arrays, objects, and function types, which leads to the magic phenomenon of you in me and me in you.

Here, we will focus for the moment on the part that starts from the type information level, i.e., the conclusion is: *Primitive type < Primitive type corresponding to the boxed type < Object type. *

Now, the type we are concerned with is Object.

Top Type

Further up the hierarchy, we reach the top of the type hierarchy (isn't that fast?), where the only two brothers are any and unknown. We know that any and unknown are the two types that are set as Top Type in the system, they ignore all causal laws and are the product of the rules of the type world. Therefore, the Object type is naturally a subtype of the any and unknown types:

type Result22 = Object extends any ? 1 : 2; // 1
type Result23 = Object extends unknown ? 1 : 2; // 1
Enter fullscreen mode Exit fullscreen mode

But what if we switch the two ends of the conditional types?

type Result24 = any extends Object ? 1 : 2; // 1 | 2
type Result25 = unknown extends Object ? 1 : 2; // 2
Enter fullscreen mode Exit fullscreen mode

You will find that any is switched over and the value becomes 1 | 2? Let's try a few more to see:

type Result26 = any extends 'linbudu' ? 1 : 2; // 1 | 2
type Result27 = any extends string ? 1 : 2; // 1 | 2
type Result28 = any extends {} ? 1 : 2; // 1 | 2
type Result29 = any extends never ? 1 : 2; // 1 | 2
Enter fullscreen mode Exit fullscreen mode

Isn't it unbelievable? Actually, it's because of the "system setting". any represents any possible type, and when we use any extends, it contains "part of the condition that holds", and "a part of that makes the condition not true". And implementation-wise, in the conditional type handling of TypeScript's internal code, if the accepted judgment is any, then it will directly return the union type consisting of the results of the conditional types.

Thus any extends string cannot simply be considered equivalent to the following conditional type:

type Result30 = ("I'm string!" | {}) extends string ? 1 : 2; // 2
Enter fullscreen mode Exit fullscreen mode

In this case, since not all members of the union type are string literal types, the condition clearly does not hold.

any is allowed to be assigned to other types, whereas unknown is only allowed to be assigned to unknown and any types, due to the "system setting" that any can be expressed as any type. You need me to assign a value to this variable? So I am now a subtype of this variable, am I being nice?

Also, the comparison between any type and unknown type holds with each other:

type Result31 = any extends unknown ? 1 : 2;  // 1
type Result32 = unknown extends any ? 1 : 2;  // 1
Enter fullscreen mode Exit fullscreen mode

Although there is still a system-setting part, we are still only concerned with the type information level hierarchy, i.e. the conclusion is: Object < any / unknown. And here we have touched the highest level of the type world, so let's go back to the literal types, only this time we have to explore downwards.

Explore downward until all things are nothing

It is much simpler to go down the list. First we can confirm that there must be a never type, because it represents a "nil" type, a type that does not exist. For such a type, it would be a subtype of any type, including, of course, literal types:

type Result33 = never extends 'linbudu' ? 1 : 2; // 1
Enter fullscreen mode Exit fullscreen mode

But then you might have thought of some special parts, such as null, undefined, void:

type Result34 = undefined extends 'linbudu' ? 1 : 2; // 2
type Result35 = null extends 'linbudu' ? 1 : 2; // 2
type Result36 = void extends 'linbudu' ? 1 : 2; // 2
Enter fullscreen mode Exit fullscreen mode

The above three cases should certainly not hold. Don't forget that in TypeScript, void, undefined, and null are actual, meaningful types, and they are not fundamentally different from string, number, or object.

With --strictNullCheckes turned off, null is treated as a subtype of string and so on. But normally we don't do that, so we don't discuss it here, but treat it as a type of the same class as string, etc.

So here we get the conclusion that never < literal type. This is the bottom level of the type world, kind of like my world where when you dig through the ground, what appears is a blanket of nothingness and emptiness.

Now then, we can start assembling the entire type hierarchy.

Type Hierarchy Chain

Combined with the conclusions we obtained above, a type hierarchy chain can be written as below:

type TypeChain = never extends 'linbudu'
  ? 'linbudu' extends 'linbudu' | '599'
  ? 'linbudu' | '599' extends string
  ? string extends String
  ? String extends Object
  ? Object extends any
  ? any extends unknown
  ? unknown extends any
  ? 8
  : 7
  : 6
  : 5
  : 4
  : 3
  : 2
  : 1
  : 0
Enter fullscreen mode Exit fullscreen mode

The result is 8, which means that all conditions hold. Of course, in combination with the structured type system and type system settings above, we can also construct a longer type hierarchy chain:

type VerboseTypeChain = never extends 'linbudu'
  ? 'linbudu' extends 'linbudu' | 'budulin'
  ? 'linbudu' | 'budulin' extends string
  ? string extends {}
  ? string extends String
  ? String extends {}
  ? {} extends object
  ? object extends {}
  ? {} extends Object
  ? Object extends {}
  ? object extends Object
  ? Object extends object
  ? Object extends any
  ? Object extends unknown
  ? any extends unknown
  ? unknown extends any
  ? 8
  : 7
  : 6
  : 5
  : 4
  : 3
  : 2
  : 1
  : 0
  : -1
  : -2
  : -3
  : -4
  : -5
  : -6
  : -7
  : -8
Enter fullscreen mode Exit fullscreen mode

The result is still 8 .

Other comparison scenarios

In addition to the type comparisons we mentioned above, there are actually some other comparison scenarios that we will add a little.

  • For base classes and derived classes, it is usually the case that derived classes will retain the structure of the base class entirely, and just add new properties and methods of their own. Under the comparison of structured types, their types will naturally have subtype relationships. Not to mention that the derived class itself is extends the base class to get.

  • The determination of the union type, earlier we only determine the single member of the union type, what if it is more than one member?

In fact, for the type hierarchy comparison of union types, we only need to compare whether a union type can be considered as a subset of another union type, i.e. all members of this union type can be found in another union type:

type Result36 = 1 | 2 | 3 extends 1 | 2 | 3 | 4 ? 1 : 2; // 1
type Result37 = 2 | 4 extends 1 | 2 | 3 | 4 ? 1 : 2; // 1
type Result38 = 1 | 2 | 5 extends 1 | 2 | 3 | 4 ? 1 : 2; // 2
type Result39 = 1 | 5 extends 1 | 2 | 3 | 4 ? 1 : 2; // 2
Enter fullscreen mode Exit fullscreen mode

In fact, for the type hierarchy comparison of union types, we only need to compare whether a union type can be considered as a subset of another union type, i.e. all members of this union type can be found in another union type.

  • Arrays and tuples

Arrays and tuples are a rather special section, let's look at the example directly:

  type Result40 = [number, number] extends number[] ? 1 : 2; // 1
  type Result41 = [number, string] extends number[] ? 1 : 2; // 2
  type Result42 = [number, string] extends (number | string)[] ? 1 : 2; // 1
  type Result43 = [] extends number[] ? 1 : 2; // 1
  type Result44 = [] extends unknown[] ? 1 : 2; // 1
  type Result45 = number[] extends (number | string)[] ? 1 : 2; // 1
  type Result46 = any[] extends number[] ? 1 : 2; // 1
  type Result47 = unknown[] extends number[] ? 1 : 2; // 2
  type Result48 = never[] extends number[] ? 1 : 2; // 1
Enter fullscreen mode Exit fullscreen mode

Let's go through them one by one.

  • 40, this tuple type actually identifies all its internal members as being of type number, and is therefore a subtype of number[]. 41 has elements of other types mixed in, so it is not considered valid.
  • 42 is mixed with another type, but its condition is (number | string)[], which means that its members need to be of type number or string.
  • The member of 43 is undetermined and is equivalent to never[] extends number[], 44 and similarly.
  • 45 similarly to 41 , i.e., the type of elements that may exist are conforming.
  • 46, 47, remember the incarnational any type and the cautious unknown type?
  • 48, similar to 43, 44, and since the never type is already at the bottom, it obviously holds here. Only the never[] type array can no longer be filled with values.

Summary

In this section, we have constructed a type hierarchy chain starting from a primitive type, going up to the Top Type and down to the Bottom Type, and in the process of construction, in addition to parent-child types, we have also learned about subtype determination of union types, subtype determination based on structured type systems, and subtype determination based on the basic rules of type systems, which basically includes all kinds of special cases.

If you find this article helpful, please don't be stingy with your love, it will support me to create more content!

Top comments (0)