preface

In the previous article “TypeScript ESLint Rule Set Considerations for Taobao Stores”, we mentioned this rule: Method-signature-style, which is used to restrict the declaration of different functions in the interface. There are two main declaration methods: method and property.

// method
interface T1 {
  func(arg: string) :number;
}

// property
interface T2 {
  func: (arg: string) = > number;
}
Copy the code

Method is like defining a method in a Class, whereas property is like defining a normal interface property, except that its value is a function type.

In TypeScript ESLint’s official interpretation of this rule, property is recommended, The most important reason is that property + function type values are declared in such a way that function types are subject to stricter type checking (strictFunctionTypes enabled, or strict only).

So what is this configuration? Why can function type validation be more rigorous, and what is the default? Why not use method declarations?

That’s where this article starts, talking about covariant and contravariant TypeScript.

Basic concept

It’s too much of a deterrent to go straight to the concept, but let’s start with an example that we see everywhere in our lives:

class Animal {
  asPet(){}}class Dog extends Animal {
  bark(){}}class Corgi extends Dog {
  cute(){}}Copy the code

Here, we have three classes that are derived sequentially, each of which adds a unique method to the last one. We use the ≼ symbol to express the subtype relationship. A ≼ B means that A is A subtype of B. In this example, it is easy to get Corgi ≼ Dog ≼ Animal.

Now we have a function that takes a dog as an argument and tries to get it to bark a few times:

function makeDogBark(dog: Dog) {
  dog.bark();
}
Copy the code

Now try calling:

makeDogBark(new Corgi());
makeDogBark(new Animal());
Copy the code

You can easily see that the first one is ok, because all corgis are dogs and can bark, but the second one, not all animals can bark, so it throws an error here. With this very simple example, you recall all of a sudden the type and the parent type, and the warm-up is over, it’s time to get serious.

Use a function as an input parameter

For another example, suppose we now have a new function that takes a function as a parameter of type Dog -> Dog (that is, both the parameter type and the return value are Dog).

type DogFactory = (args: Dog) = > Dog;

function transformDogAndBark(dogFactory: DogFactory) {
  const dog = dogFactory(new Dog());
  dog.bark();
}
Copy the code

Corgi/Animal -> Corgi/Animal

  • Corgi -> Corgi: we aretransformDogAndBarkIn, a dog is passed in, and the returned dog is asked to bark twice to listen, which seems to be ok, butCorgi -> CorgiFunction can only accept corgi, internal call corgi logic, if we pass a Shiba inu, the program may crash. But the return value is fine, because corgis and Shiba inu can bark.
  • Animal -> AnimalThe return value may be any animal, but not any animal will bark.
  • Corgi -> AnimalThe first is the problem with the parameter type, the second is the problem with the return value class, and this is the problem with the parameter type and return value.
  • Animal -> Corgi: This is the only correct answer left. If it doesn’t work, it’s crazy. So let’s do a wave first. First we’re going to introduce a Dog, ok, no problem. Animal does everything Dog does. And then we ask the returning species to bark twice, in this case the returning corgi! It can bark! So no problem!

Dog is derived from Animal, but does not modify its internal functions such as reproduction and feeding. This is known as Richter’s substitution principle: a subclass can extend the functions of its parent class, but cannot change the original functions of its parent class. Subtypes must be able to replace their base type.

To sum up the above situation, we can see that a function as an argument is allowed to have an input of the parent type of the function’s input type (Animal, Dog) and not a child type (Corgi, Dog). Its return value is allowed to be a subtype of the function’s return type (Corgi for real, Corgi for type) and not a parent (Animal for real, Dog for type).

Considering that in the simplest example above we knew that the available input type would be a subtype of the function’s input type, the following equation holds:

(Animal → Corgi) ≼ (Dog → Dog)
Copy the code

Covariant and contravariant

At this point we can introduce covariance and contravariance,

Look at these two words. After the variance meaning variance is removed, co and contra remain. For example, Collaboration is a Collaboration between contra and co. For example, Collaboration is a Collaboration.

These two words actually should have come from geometry: as a quantity changes, what changes in agreement is called covariant, and what changes in opposition is called contravariant. In this case, we call the function arguments contravariant and the function returns covariant. Why?

Consider Corgi ≼ Dog, if it follows covariation, then (T → Corgi) ≼ (T → Dog), that is, A and B still follow A consistent subtype relationship after being returned as A function value type. For parameters, as they follow the inverse, there can be (Dog → T) ≼ (Corgi → T), that is, after A and B are taken as function parameter types, the subtype relationship is reversed.

This may be a bit of a surprise, but let’s talk about people. Personally, I’m used to understanding changes in “as an amount changes” in TypeScript as a tool type Wrapper, as in:

type AsFuncArgType<T> = (arg: T) = > void;

type AsFuncReturnType<T> = (arg: unknown) = > T;

Corgi -> void ≼ Dog -> void
type CheckArgType = AsFuncArgType<Corgi> extends AsFuncArgType<Dog> ? 1 : 2;

// Established: unknown -> Corgi ≼ unknown -> Dog
type CheckReturnType = AsFuncReturnType<Corgi> extends AsFuncReturnType<Dog>
  ? 1
  : 2;
Copy the code

Wrapper ≼ Wrapper, where the Wrapper can be an implicit Wrapper like A function, or A higher-order type like Promise, Array, Record, and so on that is explicitly used as A type parameter.

When A ≼ B, covariant means Wrapper ≼ Wrapper while inverter means Wrapper ≼ Wrapper.

The proper checking logic (that is, the four cases we checked above) means that the parameter types of the function should be checked contravarially and the return value types covariant.

(Animal → Corgi) ≼ (Dog → Dog)
Copy the code

Function type checking with strictFunctionTypes by default

Remember that method-signature-style enables stricter type checking for function types (and therefore for function types declared with the property attribute). By “stricter”, we mean that inverse checking is enabled for function parameters. What you mean? To see how strictFunctionTypes work by default compared to strictFunctionTypes enabled, still using Animal, Dog, and introducing an extra Cat, consider the following function:

declare let f1: (x: Animal) = > void;
declare let f2: (x: Dog) = > void;
declare let f3: (x: Cat) = > void;
Copy the code

To check compatibility between function types, we assign between f1, F2 and f3:

  • If f1 = f2 holds, f2 ≼ f1
  • Then let f1: A1 -> B1, f2: A2 -> B2, if f1 = f2, then A2 -> B2 ≼ A1 -> B1
  • F1 = f2, i.eA2 -> B2 ≼ A1 -> B1, i.e.,Dog -> void ≼ Animal -> void
    • It states that if the distributability of function types is “seriously” checked, the parts of function arguments need to be contravariant.Animal → Corgi) ≼ (Dog → DogDog is a subclass of AnimalAnimal -> voidThe input type of the function on the left must be Animal’s parent, such as Creature. So in openingstrictFunctionTypesIn the case of this allocationDon’t set up.
    • But by default, the check for function types is bidirectional covariant in the argument part (bivariantly), i.e.,Dog ≼ AnimalIt follows thatDog -> void ≼ Animal -> voidAnimal -> void ≼ Dog -> void
  • F2 = F1, i.eAnimal -> void ≼ Dog -> void
    • This is true when strictly checked (contravariant) and by default (bidirectional covariant)
  • F2 = F3, i.eCat -> void ≼ Dog -> void
    • Cat and Dog are both subclasses of Animal, and there is no derivation relationship between them, so they cannot even meet the occurrence conditions of contravariant and covariant, so they are not valid under strict inspection and default conditions.

So why are function arguments checked bidirectionally by default? Why are function parameters bivariant?

Consider the following code:

function trainDog(d: Dog) {... }function cloneAnimalAndDoSth(source: Animal, sth: (result: Animal) => void) :void {... }let c = new Cat();
                                                                            cloneAnimalAndDoSth(c, trainDog);
Copy the code

The cloneAnimal call on the last line obviously fails because it internally passes the source to the function that takes the second argument. Here we pass the cat and the training function for the dog, which obviously doesn’t work. By default, however, this code does not return an error (because of the default bidirectional covariance).

Dog ≼ Animal -> void ≼ Dog -> voi Animal -> void ≼ TrainDog cannot be assigned to STH! But of course by default, no error is reported because of bidirectional covariance of function arguments.

Dog -> void Animal -> void Animal -> void Animal -> void Animal

  • Dog [] ≼ Animal []Is it true?
  • Can every member (attribute, method) on Dog[] be assigned to Animal[]?
    • You think I’m going to ask how Dog compares to Animal? Wrong. The question IS,Dog []. Push ≼ Animal []. PushIs it true?
    • Further deduced by the push method,Dog -> void ≼ Animal -> voidIs it true?

Here you begin to sense something is wrong. If we ask Dog[] ≼ Animal[] to be true, following this derivation, Dog -> void ≼ Animal -> void, which in the inverse case means Animal ≼ Dog, this is clearly not true. In short, whether Dog -> void ≼ Animal -> void is true in itself provides a prerequisite answer for Dog[] ≼ Animal[].

This section is the core of the introduction to this article, which uses property to declare functions in interfaces that enjoy stricter type checking. Consider using method declarations:

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

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

animalComparer = dogComparer;
dogComparer = animalComparer;
Copy the code

The first assignment is animalComparer = dogComparer; the first assignment is animalComparer; the second assignment is animalComparer; Will not hold.

Why not use method declarations? The main purpose of this is to still provide bidirectional covariant verification for some scenarios that require it, such as Array internal declarations:

interfaceArray<T> { push(... items: T[]):number; concat(... items: ConcatArray<T>[]): T[]; join(separator? :string) :string;
}
Copy the code

Other scenarios

Suppose that now the Wrapper is no longer a function body, but a simple Cage? Can Cage

be a subtype of Cage

, regardless of the internal Cage implementation, knowing that it can only hold animals of one species at a time? For this type of comparison, we can directly substitute the actual scenario, namely:

  • Suppose I need a cage of animals and don’t do anything with them other than read, then YOU can give me a cage of dogs. That means the List is readonly, andCage < Dog > ≼ Cage < Animal >Was established. That is, in an immutable Wrapper, we allow it to follow covariation.
  • Suppose I need a cage of animals, and will add other species to it, such as rabbits ah bastard, at this time you give me a cage of dogs, because this cage can only put dogs, put rabbits may be mutated. So that means that the List is writable, andCage<Dog> Cage<Rabit> Cage<Turtle>They’re mutually exclusive, which we call invariant (invariant), cages used for dogs can never be used for rabbits, i.e. they cannot be distributed.
  • If we modify the rules, now one cage can put animals of any species, dogs and rabbits can be put in the same cage, at this time, any cage can put animals of any species, the dog can put rabbits, the rabbit can also put dogs, that is to say, we can allocate each other, we call it double change (Bivariant).

This is the end of the article. In fact, covariant and contravariant concepts are relatively new to me, and since I have only learned TypeScript, there may be some inaccuracies in the description. Welcome to point out and share with us. In the next article, we’ll cover a topic that most people will be interested in: tool types and type gymnastics in TypeScript. This is a huge piece of content, and I’m pretty sure you’ll know 95% of tool types and type gymnastics by the end of it!