This article focuses on type compatibility in TypeScript and details contravariant, covariant, bidirectional covariant, and invariant through examples.

structured

In a type system based on nominal types, the compatibility or equivalence of data types is determined by explicit declarations and/or the name of the type. Structural type systems are composition structures based on types and do not require explicit declarations.

TS is a structured type system. Simply put, to determine whether two types are compatible, you just need to look at whether the structures of the two types are compatible, not whether the names of the types are the same. Such as:

interface Named {
    name: string;
}

class Person {
    name: string;
}

let p: Named;

// Compatible because it is a structured type
p = new Person();
Copy the code

In languages that use name-based types, such as C# or Java, this code will fail because the Person class does not explicitly say that it implements the Named interface.

variant

What is a variant and why is it needed? Let’s start with a simple example:

Let’s say I want to feed my cat, but I only have dog food in the house. But since both cat food and dog food are food, can I feed the dog food to the cat? Is it a safe act for a cat to eat and not get sick? That’s what we need to think about.

All variants occur between parent and child types. You may struggle to feed your cat dog food, but you certainly don’t struggle to feed your cat a piece of clothing.

Father and son type

Let’s write a simple parent-child type:

interface Animal {
  age: number
}

interface Dog extends Animal {
  bark(): void
}
Copy the code

Dog inherited from Animal and has more methods than Animal. So we say Animal is the parent and Dog is its child. Note that subtypes have more and more specific attributes than parent types:

  • In a type system, more types of attributes are subtypes.
  • In set theory, sets with fewer properties are subsets.

The parent-child type relationship needs to be paid attention to in the union type, because it can be a bit counter-intuitive. ‘a’ | ‘b’ | ‘c’ at first glance than ‘a’ | ‘b’ attribute of the more, the ‘a’ | ‘b’ | ‘c’ is’ a ‘|’ b ‘subtype? In fact, on the contrary, ‘a’ | ‘b’ | ‘c’ is’ a ‘|’ b ‘parent types, which include a wider range, while the latter is more specific.

type Parent = "a" | "b" | "c";
type Child = "a" | "b";

let parent: Parent;
let child: Child;

/ / compatible
parent = child

/ / not compatible, because the parent to c, and c cannot assign to "a" | "b"
child = parent
Copy the code

Summary:

  • The parent type is broader and covers a wider range than the child type, which is more specific than the parent type
  • A subtype must be assigned to a parent type

extends

We have already seen the parent-child type. Speaking of parent-child types, I immediately think of the extends keyword that is often used in TS. For example, in TS built-in types, we often see code like this:

type NonNullable<T> = T extends null | undefined ? never : T;
type Diff<T, U> = T extends U ? never : T; 
type Filter<T, U> = T extends U ? T : never;
Copy the code

Extends is a conditional type keyword, and the following code can be interpreted as: if T is a subtype of U, the result is X, otherwise it is Y

T extends U ? X : Y
Copy the code

Understanding conditional types is easy once you understand parent and child types.

When T is a union type, it is called a distributed conditional type. Similar to factoring in mathematics:

(a + b) * c = ac + bc
Copy the code

That is to say, when T is “A” | “B”, will be split into (” A “extends U? X : Y) | (“B” extends U ? X : Y)

type Diff<T, U> = T extends U ? never : T;

let demo: Diff<"a" | "b" | "d"."d" | "f">;

// result: "a" | "b"
Copy the code
  1. "a"not"d" | "f"Is a subset of"a"
  2. "b"Is not a “d” | “f” subset of taking"b"
  3. "d""d" | "f"Is a subset ofnever
  4. And then we get the result"a" | "b"

Covariant and contravariant

Wikipedia’s explanation of covariant and contravariant is a bit arcane. Here, let’s put it in more colloquial terms:

  • Covariant: Allows subtypes to be converted to parent types
  • Invert: Allows conversion of a parent type to a child type

Still a little hard to understand? That’s okay. We’ll talk more about that.

covariance

Covariation can be understood in terms of “duck type”. The so-called duck type is simply “if it walks like a duck and quacks like a duck, then it is a duck”. We don’t care if it’s a real duck or if it’s a chicken.

Let’s look at a piece of code:

let animal: Animal = { age: 12 };

let dog: Dog = {
  age: 12.bark: () = >{}};// compatible, can be assigned successfully, this is a covariant
animal = dog

// Incompatible, throws a type error: Property 'bark' is missing in type 'Animal' but required in type 'Dog'
dog = animal
Copy the code

In the code above, dog can be assigned to Animal. According to duck type theory, as long as a type contains age, I can consider it to be a type compatible with Animal. Thus dog can be assigned successfully to animal, while the extra bark() method can be ignored.

Conversely, animal cannot be assigned to dog. Because dog requires a dog type, it must contain age and bark, and Animal does not meet this condition.

inverter

There are two functions:

let visitAnimal = (animal: Animal): Dog= > {
  animal.age;

  return {
    age: 12.bark(){}}}let visitDog = (dog: Dog): Animal= > {
  dog.age;
  dog.bark();

  return {
    age: 20}}/ / compatible
visitDog = visitAnimal

// Incompatible, throws a type error
visitAnimal = visitDog
Copy the code

Why can visitAnimal give a value to visitDog, and vice versa? Rewrite the function above:

// before
visitDog = visitAnimal

// after
visitDog = (dog: Dog): Animal= > {
  // Input parameter dog Satisfies the Animal type required by visitAnimal
  // And the visitAnimal return value dog contains more information and matches the Animal type required by the visitDog return value
  const dog = visitAnimal(dog);
  return dog.age;
}
Copy the code

Doesn’t that make sense? To assign visitAnimal to visitDog, call visitAnimal from visitDog. The visitAnimal input needs to be of the Animal type, while dog, with more information, obviously satisfies this requirement.

If not, we can use the above method to rewrite:

// before
visitAnimal = visitDog

// after 
visitAnimal = (animal: Animal): Dog= > {
  // Input animal The type of Dog that does not meet the visitDog input requirements
  // And the visitDog return value animal does not match the Dog type required by visitDog. Calling animal.bark() causes an error
  const animal = visitDog(animal); 
  return animal;
}
Copy the code

Based on the above implementation, the following two function types can be abstracted:

let haveAnimal: (animal: Animal) = > Dog;
let haveDog: (dog: Dog) = > Animal;
Copy the code

Among them:

  • The function argument is inverse: Animal converts to Dog, parent type -> child type
  • The return value is covariant: Dog becomes Animal, child type -> parent type
// You can imagine the following type
interface Fn {
  params: any[]; / / inverter
  return: any;  / / covariant
}
Copy the code

Two-way covariance

In older versions of TS, function arguments were bidirectional covariant. That is, it can be both covariant and contravariant, but this is not type-safe. In the new version of TS (2.6+), you can fix this by turning on strictFunctionTypes or strict. After this is set, function parameters are no longer bidirectional covariant.

The same

Invariance is pretty straightforward, it just doesn’t allow you to change. For example, if I want a pear, you must give me a pear, apple, banana, any fruit is not allowed.

interface Duck {
  name: string;
  age: number;
  city: string;
}

const fakeDuck: Duck = {
  name: "aDuck".age: 12.city: "America".price: 100  // Type error because there is an extra price attribute
}
Copy the code

conclusion

Whether covariant or contravariant, it all comes down to providing some flexibility while keeping your type safe. We also don’t have to remember what contravariant and covariant are. When we encounter problems, we can simply rewrite the expression according to the method in the article to know whether this use is type-safe. In the safe case, we can accept the type that is not exactly the same as the current definition.

Reference:

  • What are subtypes, contravariants, and covariants in TypeScript?
  • TypeScript type system covariant and contravariant understanding of function types
  • TS Official Documentation
  • In-depth understanding of TS- covariant and contravariant