1
头图

Preface

There are many places in TypeScript that involve the subtype subtype 061b94a41acc21, supertype supertype , covariant Covariant , contravariant Contravariant , bidirectional covariant Bivariant and invariant Invariant . If you don’t understand these concepts, you may be mistaken. , Or when I was writing some complex types, I saw that others could write like this, but I don’t know the reason for it.

extends keyword

In TypeScript, the extends keyword has the following three meanings in different application scenarios:

  1. means inheritance/expansion:

Inherit the methods and properties of the parent class

class Animal {
  public weight: number = 0
  public age: number = 0
}

class Dog extends Animal {
  public wang() {
    console.log('汪!')
  }
  public bark() {}
}

class Cat extends Animal {
  public miao() {
    console.log('喵~')
  }
}

Inheritance type

interface Animal {
  age: number
}

interface Dog extends Animal {
  bark: () => void
}
 // Dog => { age: number; bark(): void }
  1. indicates the meaning of constraint

When writing generics, we often need to impose certain restrictions on type parameters. For example, if we want the incoming parameters to have an array with the name attribute, we can write:

function getCnames<T extends { name: string }>(entities: T[]):string[] {
  return entities.map(entity => entity.name)
}
  1. represents the meaning of allocation ( assignable )
type Animal = {
  name: string;
}
type Dog = {
  name: string;
  bark: () => void
}
type Bool = Dog extends Animal ? 'yes' : 'no';

The following focuses on some usages that indicate the meaning of assignment, that is, assignability

Literal type matching

type Equal<X, Y> = X extends Y ? true : false;

type Num = Equal<1, 1>; // true
type Str = Equal<'a', 'a'>;
type Boo1 = Equal<true, false>;
type Boo2 = Equal<true, boolean>; // true

easy to confuse: Type X can be assigned to type Y , instead of saying that type X is a subset of type Y

never

Some examples of its natural distribution:

  • A function that never returns a value (for example, if the function contains while(true) {} );
  • Always a function throws an error (such as: function foo() { throw new Error('Not Implemented') } , foo return type is never );

never is a subtype of all types

type A = never extends 'x' ? string : number; 

type P<T> = T extends 'x' ? string : number;
type B = P<never>

Complex type value matching

class Animal {
  public weight: number = 0
  public age: number = 0
}

class Dog extends Animal {
  public wang() {
    console.log('wang')
  }
  public bark() {}
}

class Cat extends Animal {
  public miao() {
    console.log('miao')
  }
}

type Equal<X, Y> = X extends Y ? true : false;
type Boo = Equal(Dog, Animal)
type Boo = Equal(Animal, Dog)

type Boo = Equal(Animal, Dog) // false This is because Animal does not have the bark attribute, and the type Animal does not satisfy the type constraint of the Dog Therefore, A extends B , refers type A can assigned to the type B , rather than that type A is the type B subset , understanding extends usage type in a triplet of expressions is very important.

Parent-child type

Or use the animal metaphor as a metaphor:

interface Animal {
  age: number
}

interface Dog extends Animal {
  bark: () => void
}

let animal: Animal
let dog: Dog

In this example, Animal is Dog parent class, Dog is Animal subtype, subtype attribute more than the supertype, and more specifically.

  • In the type system, more types of attributes are subtypes.
  • In set theory, a set with fewer attributes is a subset.

In other words, the subtype is a superset of the supertype, and the supertype is a subset of the subtype, which is intuitively easy to confuse.

Remember a feature, the subtype is more specific , this is very important.

As you can see from the above example, animal is a "broader" type with fewer attributes, so more "specific" subtypes can be assigned to it, because you know that animal only age the attribute 061b94a41acfda. You will only use this attribute. dog has all the types owned by animal . Assigning a value to animal will not type safety issues .

Conversely, if dog = animal , then subsequent users will expect dog to have the bark attribute, and when he calls dog.bark() it will cause a runtime crash.

, the subtype can be assigned to the supertype, that is, 161b94a41ad011 supertype variable = subtype variable is safe, because the subtype covers all the properties of the supertype.

When I was a beginner, I would think T extends {} is very strange. Why can extends an empty type and it is true when any type is passed? When the above knowledge points are understood, this problem will naturally be solved.

So far, I have basically finished the three uses of extends. The following enters the topic: contravariant covariance, two-way covariation and invariance


origin

Ts has been written for a long time, and once when I needed to pass an onClick time function type when writing a prop type for a certain component, a question suddenly came to my mind:

Why are the function types defined in the interface written as function attributes instead of methods, namely:

interface Props {
  handleClick: (arg: string) => number   // 普遍写法
  handleClick(arg: string): number  // 非主流写法
}

Finally encountered this rule when I saw the rule set in typescript-eslint

@typescript-eslint/method-signature-style

The rule case is as follows:

❌ Incorrect

interface T1 {
  func(arg: string): number;
}
type T2 = {
  func(arg: boolean): void;
};
interface T3 {
  func(arg: number): void;
  func(arg: string): void;
  func(arg: boolean): void;
}

✅ Correct

interface T1 {
  func: (arg: string) => number;
}
type T2 = {
  func: (arg: boolean) => void;
};
// this is equivalent to the overload
interface T3 {
  func: ((arg: number) => void) &
    ((arg: string) => void) &
    ((arg: boolean) => void);
}

A method and a function property of the same type behave differently. Methods are always bivariant in their argument, while function properties are contravariant in their argument under strictFunctionTypes.

Methods and function properties of the same type behave differently. Methods are always double-variable in their parameters, while function attributes are contravariant in parameters under strict function types.

After seeing this sentence, I also looked confused. It was the first time I saw the two words two-way covariance and contravariance, so I looked up the information and found their concept and extended covariance and invariance.

Inverter covariance

The first paragraph Wikipedia definition :

Covariance and contravariance (covariance and contravariance) is the description of multiple types with parent/child relationship in computer science. Through the type constructor, multiple complex types constructed by the type constructor, whether there is a parent/child The terminology of type relationship.

Hey, the parent/child relationship seems to have been mentioned before, and then when it comes to contravariance and covariance, it is also necessary to mention the above-mentioned distributability, which is why the article has to spend a lot of time at the beginning of the article to introduce the extends keywords The reason for determining the assignability between types in ts is based on the structured type ( structural typing )

Covariance

So imagine that now we have these two sub-type arrays, what should the parent-child relationship between them look like? That's right, Animal[] still the Dog[] . For such a piece of code, it is still safe (compatible) to assign the subtype to the supertype:

interface Animal {
  age: number
}

interface Dog extends Animal {
  bark: () => void
}
let animals: Animal[]
let dogs: Dog[]

// 你用了一个更加具体的类型去接收原来的Animal类型了,此时你的类型是安全的,animal有的dog肯定有
animals = dogs  // 兼容,子(Dog)变父(Animal)(多变少)只要一个类型包含 age,我就可以认为它是一个和 Animal 兼容的类型。因此 dog 可以成功赋值给 animal,而对于多出来的 bark() 方法,可以忽略不计。

dog = animal // 不兼容

After being converted into an array, for the variables of the parent type, we still only look for Dog type (because the subtype is more specific, and the parent type has attributes and subtypes)

Then, for the type MakeArray<T> = T[] this type of constructor, it is covariance (Covariance) .

Contravariance

Inverter is really difficult to understand, first do a (no) interesting (chat) question (source: "In-depth understanding of TypeScript")

Before starting to do the questions, we first agree on the following marks:

  • A ≼ B means that A is a B .
  • A → B refers to A as the parameter type and B as the return value type.
  • x : A means x type of A .

Question : Which of the following types is a Dog → Dog ?

  1. Greyhound → Greyhound
  2. Greyhound → Animal
  3. Animal → Animal
  4. Animal → Greyhound

Let us think about how to answer this question. First we assume that f is a Dog → Dog as the parameter. Its return value is not important. In order to describe the problem in detail, we assume that the function structure is like this: f : (Dog → Dog) → String .

Now I want to function f passed a function g to call. Let's see g is of the above four types.

1. We assume that g : Greyhound → Greyhound , f(g) type is safe?

It is not safe, because when calling its parameter (g) function in f, the parameter used may be a subtype different from Greyhound but also a dog, such as GermanShepherd (shepherd dog).

2. We assume g : Greyhound → Animal , is the type of f(g)

Not safe. The reason is the same as (1).

3. We assume g : Animal → Animal , is the type of f(g)

Not safe. Because f may return the value after calling the parameters, which is Animal (animal) dog barking. Not all animals can bark.

4. We assume that the type of g : Animal → Greyhound , f(g)

Yes, its type is safe. First, f may be called with any dog breed as a parameter, and all dogs are animals. Second, it might assume that the result is a dog, and all greyhounds are dogs.

That is to say: after calling the following type constructors on the type:

type MakeFunction<T> = (arg: T) => void

The parent-child type relationship is reversed (understand by the above question: Animal → Greyhound is a subtype of Dog -> Dog, but Animal is a supertype of Dog). This is Contravariance .

Through this example, you can find:

  • Return value -> Covariance (Greyhound -> Dog)
  • Entry should usually be contravariant (Animal <- Dog)

Two-way covariance

In the TS duck type system, as long as the two objects have the same structure, they are considered to be the same type, and there is no need for the actual types of the two to have an explicit inheritance relationship.

Function attributes and function methods

After understanding these two concepts, we can roughly guess the definitions of two-way covariance and invariance. Two-way covariance means that both covariance and contravariance can be used. On the other hand, the same can be neither covariant nor contravariant. Now we first To the point of confusion before: Why is it recommended to define function types in interface Props{} with the wording of function attributes?

Use official to illustrate this problem again:

declare let f1: (x: Animal) => void;
declare let f2: (x: Dog) => void;
declare let f3: (x: Cat) => void;
f1 = f2;  // Error with --strictFunctionTypes 
f2 = f1;  // Ok
f2 = f3;  // Error

The first assignment is allowed in the default type checking mode, but is marked as an error in the strict function typing mode. Intuitively, the default mode allows assignment, because it may be reasonable, but strictly a function of the type of model makes it a mistake, because it can not prove reasonable. In either mode, the third assignment is wrong, because it is reasonable will never be

Another way to describe the example is that the type (x: T) => void is double in the default type checking mode (that is, the covariant or contravariant) T , but in the strict function type mode, it is contravariant to T .

interface Comparer<T> {
    compare(a: T, b: T): number;
}

declare let animalComparer: Comparer<Animal>;
declare let dogComparer: Comparer<Dog>;

animalComparer = dogComparer;  // Ok because of bivariance
dogComparer = animalComparer;  // Ok (逆变)

In --strictFunctionTypes mode, the first assignment is still allowed, because it is declared as a method compare In fact, T is double variable, Comparer<T> because it is only used for method parameter positions. However, changing compare to have a function type attribute will cause stricter checks to take effect:

interface Comparer<T> {
    compare: (a: T, b: T) => number;
}

declare let animalComparer: Comparer<Animal>;
declare let dogComparer: Comparer<Dog>;

animalComparer = dogComparer;  // Error
dogComparer = animalComparer;  // Ok

Conclusion: In strict mode (or strictFunctionTypes): type safety issues will be guaranteed, on the contrary, the default two-way covariance may make you unsafe when using types!

Array

First raise a question: Array<Dog> be a Array<Animal> ? (Source: "In-depth understanding of TypeScript")

First look at the following example:

interface Animal {
  name: string
}

interface Dog extends Animal {
  wang: () => void
}

interface Cat extends Animal {
  miao: () => void
}

const dogs: Array<Dog> = []
const animals: Animal[] = dogs
// Array入参在ts中是双向协变的
animals.push(new Cat())

If the list is immutable, then the answer is yes, because the type is safe. But if the list is mutable, then the answer is absolutely no!

Variable data

If you look at the type of Array in typescript, you can see that the Array type definition is written as a function method. Therefore, its input parameters are two-way covariant!

interface Array<T> {
    length: number;
    toString(): string;
    toLocaleString(): string;
    pop(): T | undefined;
    push(...items: T[]): number;
    concat(...items: ConcatArray<T>[]): T[];
    concat(...items: (T | ConcatArray<T>)[]): T[];
    join(separator?: string): string;
    reverse(): T[];
    shift(): T | undefined;
    slice(start?: number, end?: number): T[];
    sort(compareFn?: (a: T, b: T) => number): this;
    splice(start: number, deleteCount?: number): T[];
    splice(start: number, deleteCount: number, ...items: T[]): T[];
    unshift(...items: T[]): number;
    indexOf(searchElement: T, fromIndex?: number): number;
    lastIndexOf(searchElement: T, fromIndex?: number): number;
    every(callbackfn: (value: T, index: number, array: T[]) => boolean, thisArg?: any): boolean;
    some(callbackfn: (value: T, index: number, array: T[]) => boolean, thisArg?: any): boolean;
    forEach(callbackfn: (value: T, index: number, array: T[]) => void, thisArg?: any): void;
    map<U>(callbackfn: (value: T, index: number, array: T[]) => U, thisArg?: any): U[];
    filter<S extends T>(callbackfn: (value: T, index: number, array: T[]) => value is S, thisArg?: any): S[];
    filter(callbackfn: (value: T, index: number, array: T[]) => any, thisArg?: any): T[];
    reduce(callbackfn: (previousValue: T, currentValue: T, currentIndex: number, array: T[]) => T): T;
    reduce(callbackfn: (previousValue: T, currentValue: T, currentIndex: number, array: T[]) => T, initialValue: T): T;
    reduce<U>(callbackfn: (previousValue: U, currentValue: T, currentIndex: number, array: T[]) => U, initialValue: U): U;
    reduceRight(callbackfn: (previousValue: T, currentValue: T, currentIndex: number, array: T[]) => T): T;
    reduceRight(callbackfn: (previousValue: T, currentValue: T, currentIndex: number, array: T[]) => T, initialValue: T): T;
    reduceRight<U>(callbackfn: (previousValue: U, currentValue: T, currentIndex: number, array: T[]) => U, initialValue: U): U;
    [n: number]: T;
}

Variable array + two-way covariance cannot guarantee type safety

Safer array type

interface MutableArray<T> {
        length: number;
    toString: string;
    toLocaleString(): string;
    pop: () => T | undefined;
    push: (...items: T[]) =>  number;
    concat:(...items: ConcatArray<T>[]) => T[];
    join: (separator?: string) => string;
    reverse: () => T[];
    shift:() => T | undefined;
    slice:(start?: number, end?: number) => T[];
    sort:(compareFn?: (a: T, b: T) => number) => this;
    indexOf: (searchElement: T, fromIndex?: number) => number;
      // ...
}

(Thinking: why) At this time, we will find that MutableArray is actually an immutable type and can no longer be assigned to each other

const dogs: MutableArray<Dog> = [] as Dog[];
// error
const animals: MutableArray<Animal> = dogs;

const animals: MutableArray<Animal> = [] as Animal[] ;
// error
const dogs: MutableArray<Dog> = animals

The reason is that the Array type has both the contravariant method push and the covariant method pop (meeting the conditions of mutual allocation? Assuming that they are satisfied, then if the pop and push methods in MutableArray<Dog> and MutableArray<Animal> are compatible? The parameters need to be satisfied at the same time. Inverter and return value covariance can be compatible)

Summarize

  • You can use readonly to mark attributes to make them immutable
  • More use of function attributes instead of function methods to define types
  • Try to separate the covariance or contravariance in the type, or make the type immutable
  • Avoid two-way covariance as much as possible

Reference

[1]@typescript-eslint/method-signature-style: https://github.com/typescript-eslint/typescript-eslint/blob/main/packages/eslint-plugin/docs/rules/method-signature-style.md

[2]PR: https://github.com/microsoft/TypeScript/pull/18654


MangoGoing
785 声望1.2k 粉丝

开源项目:详见个人详情