Desmond

Desmond

An introvert who loves web programming, graphic design and guitar
github
bilibili
twitter

TypeScript Type System

Structured Type System#

class Cat {
  meow() { }
  eat() { } // Removing eat will not cause an error
}

class Dog {
  eat() { }
}

function feedCat(cat: Cat) { }

// Error!
feedCat(new Dog())

This is because TypeScript compares two types not by their names (i.e., the feedCat function can only be called with the Cat type), but by comparing the actual properties and methods they have. In other words, it is actually comparing whether the properties on the Cat type all exist on the Dog type.

In our initial example, the methods on the Cat and Dog types are the same, so even though they are two types with different names, they are still considered structurally equivalent. This is a feature of the structured type system. You may have heard the term "duck typing" for structural typing, which comes from the duck test. The core idea is that if you see a bird that walks like a duck, swims like a duck, and quacks like a duck, then that bird is a duck.

class Cat {
  eat() { }
}

class Dog {
  bark() { }
  eat() { }
}

function feedCat(cat: Cat) { }

feedCat(new Dog())

Why is there no type error this time? This is because the structured type system considers the Dog type to fully implement the Cat type. The additional method bark can be considered as a new method added after inheriting the Cat type, so at this point, the Dog class can be considered a subclass of the Cat class. Similarly, the Liskov substitution principle in object-oriented programming also mentions the duck test: if it looks like a duck, quacks like a duck, but needs batteries to work, then your abstraction is likely wrong.

Furthermore, when comparing the properties of object types, the structured type system is also used. When comparing function types (i.e., methods) in a structure, there is also a compatibility comparison of types:

class Cat {
  eat(): boolean {
    return true
  }
}

class Dog {
  eat(): number {
    return 599;
  }
}

function feedCat(cat: Cat) { }

// Error!
feedCat(new Dog())

Nominal Type System#

The nominal type system requires that two compatible types must have the exact same name, for example, the following code:

type USD = number;
type CNY = number;

const CNYCount: CNY = 200;
const USDCount: USD = 200;

function addCNY(source: CNY, input: CNY) {
  return source + input;
}

addCNY(CNYCount, USDCount)

In the nominal type system, CNY and USD are considered as two completely different types, so this situation can be avoided. In the book "Programming and Type Systems," it is mentioned that one of the important meanings of types is to limit the available operations and actual meanings of data, which is more obvious in the nominal type system. For example, based on the structure of types, we can make the structural type system believe that two types have a parent-child relationship, while in the nominal type system, the parent-child relationship can only be achieved through explicit inheritance, which is called nominal subtyping.

Simulating Nominal Type System in TypeScript#

export declare class TagProtector<T extends string> {
  protected __tag__: T;
}

export type Nominal<T, U extends string> = T & TagProtector<U>;

Here we use TagProtector to declare a class with a protected property, which is used to carry additional information and merge it with the original type to obtain the Nominal utility type.

export type CNY = Nominal<number, 'CNY'>;

export type USD = Nominal<number, 'USD'>;

const CNYCount = 100 as CNY;

const USDCount = 100 as USD;

function addCNY(source: CNY, input: CNY) {
  return (source + input) as CNY;
}

addCNY(CNYCount, CNYCount);

// Error!
addCNY(CNYCount, USDCount);

Type Hierarchy#

image

Ways to Determine Type Compatibility#

For the variable a = variable b, if it is true, it means that <type of variable b> extends <type of variable a> is true, which means that the type of b is a subtype of the type of a.

type Result = 'linbudu' extends string ? 1 : 2;
declare let source: string;

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

anyType = source;

// Type 'string' is not assignable to type 'never'.
neverType = source;

Basic Types and 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

It is obvious that a literal type and its corresponding primitive type must have a parent-child relationship. Strictly speaking, the use of object here is not appropriate because it actually represents all non-primitive types, including array, object, and function types. So the reason Result6 is true is that the literal type [] can also be considered as a literal type of object. We can summarize this as literal type < corresponding primitive type.

Union Types#

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
type Result10 = string extends string | false | number ? 1 : 2; // 1

Literal types in a union type < the union type containing this literal type, primitive types < the union type containing this primitive type

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

Literal union types of the same basic type < this basic type

Combining the conclusions, except for some special cases, we get the final conclusion: literal type < union type containing this literal type (same basic type) < corresponding primitive type, i.e.:

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

Boxing Types#

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

Primitive types < boxing types corresponding to primitive types < Object type

Top Type#

type Result22 = Object extends any ? 1 : 2; // 1
type Result23 = Object extends unknown ? 1 : 2; // 1
type Result24 = any extends Object ? 1 : 2; // 1 | 2
type Result25 = unknown extends Object ? 1 : 2; // 2
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

In the conditional type handling of internal TypeScript code, if the type being compared is any, it will directly return a union type composed of the conditional type results.

type Result31 = any extends unknown ? 1 : 2;  // 1
type Result32 = unknown extends any ? 1 : 2;  // 1

Object < any / unknown

Bottom Type#

type Result33 = never extends 'linbudu' ? 1 : 2; // 1
type Result34 = undefined extends 'linbudu' ? 1 : 2; // 2
type Result35 = null extends 'linbudu' ? 1 : 2; // 2
type Result36 = void extends 'linbudu' ? 1 : 2; // 2

never < literal types

Other Comparison Scenarios#

  • For base classes and derived classes, in most cases, the derived class will completely retain the structure of the base class, not just add new properties and methods. Under the comparison of structural typing, their types naturally have a parent-child relationship. Not to mention that the derived class itself is obtained by extending the base class.
  • For the comparison of union types, we only need to compare whether one union type can be considered a subset of another union type, that is, whether all members in this union type can be found in the other 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
  • Arrays and Tuples
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

References:
https://juejin.cn/book/7086408430491172901

Loading...
Ownership of this post data is guaranteed by blockchain and smart contracts to the creator alone.