preface
There are many places in TypeScript that involve the concepts of subtype, supertype, Covariant, Contravariant, bidirectional Covariant and Invariant. If you don't understand these concepts, you may be reported as wrong and have no way to start, or you can see others write like this when writing some complex types, But I don't know why.
extends keyword
In TypeScript, the extends keyword has the following three meanings in different application scenarios:
- Meaning of 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('Woof!') } public bark() {} } class Cat extends Animal { public miao() { console.log('Meow~') } }
Inheritance type
interface Animal { age: number } interface Dog extends Animal { bark: () => void } // Dog => { age: number; bark(): void }
- Indicates the meaning of the constraint
When writing generics, we often need to limit the type parameters. For example, if we want the passed parameters to have an array with name attribute, we can write this:
function getCnames<T extends { name: string }>(entities: T[]):string[] { return entities.map(entity => entity.cname) }
- Indicates the meaning of assignment (assignable)
type Animal = { name: string; } type Dog = { name: string; bark: () => void } type Bool = Dog extends Animal ? 'yes' : 'no'; // 'yes'
The following focuses on some usage of assignment meaning, that is, assignability
Simple value matching
type Equal<X, Y> = X extends Y ? true : false; type Num = Equal<1, 1>; // true type Str = Equal<'a', 'a'>; // true type Boo1 = Equal<true, false>; // false type Boo2 = Equal<true, boolean>; // true
Type X can be assigned to type Y, rather than saying that type X is a subset of type y
never
Some examples of its natural assignment:
- A function that never returns a value (for example, if the function contains while(true) {});
- A function that always throws errors (for example, function foo() {throw new error ('not implemented ')}, and the return type of foo is never);
never is a subtype of all types
type A = never extends 'x' ? string : number; // string type P<T> = T extends 'x' ? string : number; type B = P<never> // string
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) // true type Boo = Equal(Animal, Dog) // false
type Boo = Equal(Animal, Dog) // false this is because Animal does not have a bark attribute, and type Animal does not satisfy the type constraint of type Dog. Therefore, A extends B means that type a can be assigned to type B, rather than that type A is a subset of type B. It is very important to understand the usage of extends in type ternary expressions.
Parent child type
Or animal analogy:
interface Animal { age: number } interface Dog extends Animal { bark: () => void } let animal: Animal let dog: Dog
In this example, Animal is the parent class of Dog, and Dog is the subtype of Animal. The properties of the subtype are more specific than the parent type.
- In the type system, more types of attributes are subtypes.
- In set theory, sets with fewer attributes are subsets.
In other words, subtypes are supersets of parent types, and parent types are subsets of subtypes, which is intuitively confusing.
It is critical to remember that a subtype is more specific than a parent.
As can be seen 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 age is the only attribute on animal, you will only use this attribute, and dog has all types owned by animal, Assigning to animal will not cause type safety problems.
On the contrary, if dog = animal, the subsequent user will expect the dog to have the bark attribute when he calls dog Bark () will cause a runtime crash.
From the perspective of assignability, subtypes can be assigned to the parent type, that is, parent type variables = subtype variables are safe, because subtypes cover all the attributes owned by the parent type.
When I first learned it, I would find it strange that statements such as T extends {} can extend an empty type and hold true when passing any type? When we understand the above knowledge points, this problem will be solved naturally.
So far, I have basically finished talking about the three uses of extends, and now I come to the main topic: inverse covariance, bidirectional covariance and invariance
origin
ts has been written for a long time. Once, when writing props type for a component, you need to pass an onClick time function type, a problem suddenly comes to mind:
Why are function types defined in the interface written as function attributes rather than methods, that is:
interface Props { handleClick: (arg: string) => number // Universal writing handleClick(arg: string): number // Non mainstream writing }
Finally, I encountered this rule when I saw the rule set in typescript eslint
@typescript-eslint/method-signature-style
The rule cases are 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 bivariate in their parameters, while function attributes are inverse in parameters under strict function types.
After seeing this sentence, I also looked confused. I saw the two words of two-way covariance and inversion for the first time, so I consulted the data to find their concepts and extended covariance and invariance
Inverse covariance
First paragraph Definition of Wikipedia:
Covariance and contravariance is a term used in computer science to describe whether there is a parent / child type relationship between multiple types with parent / child type relationship and multiple complex types constructed through type constructors.
Eh, the parent / child type relationship seems to have been mentioned earlier, and then the inversion and covariance, as well as the allocability mentioned earlier. That's why we spend a lot of time introducing the extends keyword at the beginning of the article. Determining the allocability between types in ts is based on structural typing
Covariance
So imagine that now we have these two subtype arrays respectively. What should be the parent-child relationship between them? Yes, Animal [] is still the parent type of Dog []. For such a piece of code, it is still safe to assign a subtype to the parent type:
let animals: Animal[] let dogs: Dog[] animals = dogs animals[0].age // ✅ok
After being transformed into an array, we will still only look for the attributes that must exist in the Dog type for the variables of the parent type (because the subtype is more specific, and all the attributes of the parent type have subtypes)
Then, for the type constructor type makearray < T > = t [], it is Covariance.
Contravariance
Inversion is really difficult to understand. First, make an interesting (ChAT) question (source: in-depth understanding of TypeScript)
Before starting the topic, we agree on the following marks:
- A ≼ B means that a is a subtype of B.
- A → B refers to the function type with a as the parameter type and B as the return value type.
- x: A means that the type of x is A.
Question: which of the following types is a subtype of Dog → Dog?
- Greyhound → Greyhound
- Greyhound → Animal
- Animal → Animal
- Animal → Greyhound
Let's think about how to answer this question. First, we assume that f is a function with Dog → Dog as the parameter. Its return value is not important. In order to specifically describe the problem, we assume that the function structure is as follows: F: (Dog → Dog) → String.
Now I want to pass in some function g to function f to call. Let's see what happens when G is of the above four types.
1. We assume that G: Greyhound → Greyhound, is the type of f(g) safe?
Unsafe, because when calling its parameter (g) function in f, the parameter used may be a subtype different from greyhound but a dog, such as German shepherd.
2. We assume that the type of G: Greyhound → Animal, f(g) is safe?
unsafe. The reason is the same as (1).
3. We assume that G: Animal → Animal, is the type of f(g) safe?
unsafe. Because f it is possible to let the return value, that is, Animal dog bark, after calling the parameter. Not all animals bark.
4. We assume that G: animal → Greyhound, is the type of f(g) safe?
Yes, its type is safe. First, f may be called with any dog breed as a parameter, and all dogs are animals. Second, it may assume that the result is a dog and that all greyhounds are dogs.
That is, after calling the following type constructors for types:
type MakeFunction<T> = (arg: T) => void
The parent-child type relationship is reversed (understood by the above question: Animal → Greyhound is the subtype of Dog - > Dog, but Animal is the parent type of Dog). This is Contravariance.
Through this example, it can be found that:
- Return value - > covariance (Greyhound - > dog)
- Input parameters should usually be inverse (animal < - Dog)
Function properties and function methods
After understanding these two concepts, we can roughly guess the definitions of two-way covariance and invariance. Two-way covariance can be both covariant and invertible. On the contrary, it can neither be covariant nor invertible. Now let's go to the previous confusion: why is it recommended to use the writing of function attributes to define function types in interface Props {}?
First understand a knowledge point: what is the difference between the two writing methods?
// Try in tsconfig TS = > compileroptions = > Add / remove the rule "strictFunctionTypes": true. View the following results respectively interface Animal { name: string } interface Dog extends Animal { wang: () => void } interface Cat extends Animal { miao: () => void } // This can be compared with the Props interface definition interface Comparer<T> { compare(a: T, b: T): number // compare: (a: T, b: T) => number; } declare let animalComparer: Comparer<Animal> declare let dogComparer: Comparer<Dog> animalComparer = dogComparer // ??? dogComparer = animalComparer // Ok
Let's take a look at the difference between the two results compiled into js?
class Handler { constructor() { this.handleDebounce(); this.handleThrottle(); } handleDebounce() { console.log('Anti shake function', this); } handleThrottle = () => { console.log('Throttling function', this); } } let handler = new Handler();
Compile.... =>
var Handler = /** @class */ (function () { function Handler() { var _this = this; this.handleThrottle = function () { console.log('Throttling function', _this); }; this.handleDebounce(); this.handleThrottle(); } Handler.prototype.handleDebounce = function () { console.log('Anti shake function', this); }; return Handler; }()); var handler = new Handler();
It can be seen that handledebunce is written on the prototype object, and the general methods of the native object are also written on the prototype. In fact, this behavior of typescript is to be compatible with js behavior and take into account the development experience.
Reuse official The following two examples 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 type mode. Intuitively, the default mode allows assignment because it may be reasonable, while the strict function type mode makes it an error because it cannot be proved to be reasonable. In either mode, the third assignment is wrong because it will never be reasonable.
Another way to describe the example is that the type is bivariate (i.e. covariant or inverse) t in the default type check mode (X: T) = > void, but inverse t in the strict function type mode.
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 -- strictFunctionTypesmode, the first assignment is still allowed because it compares is declared as a method. In fact, t is bivariate, comparer < T > because it is only used for method parameter positions. However, changing compare to a property with a function type will result in more stringent checks taking 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 strict function types): type safety will be guaranteed. On the contrary, the default two-way covariance may make you unsafe when using types!
Array
First throw a question: can list < dog > be a subtype of list < animal >? (source: understanding TypeScript in depth)
Let's take a 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 is bidirectional covariant in ts animals.push(new Cat())
If the list is immutable, the answer is yes, because the type is safe. But if the list is variable, the answer is definitely no!
Variable data
If you look at the Array type of typescript, you can see that the Array type definition writes function methods. Therefore, its input parameters are bidirectional 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 + bidirectional covariance cannot guarantee type safety
Safer array types
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; // ... }
At this point, we will find that MutableArray is actually an immutable type and can no longer be allocated 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 inverse method push and the covariant method pop, and even the immutable method concat
summary
- You can use readonly to mark attributes so that they are immutable
- Use function properties more than function methods to define types
- Try to separate covariances or inversions in types, or make types immutable
- Avoid two-way covariance as much as possible
reference material
[1]@typescript-eslint/method-signature-style: https://github.com/typescript...