suemor

suemor

前端萌新
telegram
github
twitter

TypeScript Type Hierarchy, Contravariance, Covariance, Bidirectional Covariance, and Invariance

I also recently came into contact with this knowledge, and there may be some errors in the article. I hope experts can provide more guidance.

Understanding the concepts of contravariance, covariance, bidirectional covariance, and invariance is important for learning TypeScript and types. However, as long as you understand the parent-child relationship of types, it will be much easier to understand these concepts. Therefore, before discussing these concepts, we must first learn the parent-child relationship of types.

Parent-Child Relationship of Types#

First, let's clarify a concept. For TypeScript, as long as the type structures are consistent, the parent-child relationship can be determined, which is different from Java (Java requires the use of "extends" for inheritance).

Let's take a look at the following example:

interface Person {
    name: string;
    age: number;
} 

interface Suemor {
    name: string;
    age: number;
    hobbies: string[]
}

You should be able to see that these two types have an inheritance relationship. Now, you may wonder who is the parent and who is the child?

You might think that Suemor is the parent type of Person (after all, Person has 2 properties, while Suemor has 3 properties including Person), but that's incorrect.

In the type system, the type with more properties is the subtype. In other words, Suemor is the subtype of Person.

Because this is counterintuitive, it may be difficult for you to understand (I couldn't understand it at first either). You can try to understand it like this: Because A extends B, A can extend the properties of B. Therefore, A often has more properties than B, so A is the subtype. Or you can remember one characteristic: Subtypes are more specific than supertypes.

When determining the parent-child relationship of union types, which is more specific: 'a' | 'b' or 'a' | 'b' | 'c'?

'a' | 'b' is more specific, so 'a' | 'b' is the subtype of 'a' | 'b' | 'c'.

Covariance#

Application in Objects#

Covariance is easy to understand and is often used in development. For example:

interface Person {
    name: string;
    age: number;
} 

interface Suemor {
    name: string;
    age: number;
    hobbies: string[]
}

let person: Person = { // parent
    name: '',
    age: 20
};
let suemor: Suemor = { // child
    name: 'suemor',
    age: 20,
    hobbies: ['play game', 'coding']
};

// Correct
person = suemor;
// Error, if your editor does not show an error, please enable strict mode. The reason will be explained in bidirectional covariance.
suemor = person;

Although these two types are different, suemor can be assigned to person, which means the child type can be assigned to the parent type. The reverse is not true (think about what would happen if person could be assigned to suemor and you called suemor.hobbies in your program).

Therefore, the conclusion is: Covariance refers to the situation where a subtype can be assigned to a supertype.

Application in Functions#

Covariance can also be used in functions. For example:

interface Person { 
  name: string;
  age: number;
}

function fn(person: Person) {} // parent

const suemor = { // child
  name: "suemor",
  age: 19,
  hobbies: ["play game", "coding"],
};

fn(suemor);

fn({
  name: "suemor",
  age: 19,
  // Error
  // Here's a little extra knowledge (because I made a mistake when I learned it): the error occurs because hobbies is assigned directly without type inference.
  hobbies: ["play game", "coding"] 
})

Here, we have added an additional property, hobbies. Similarly, because of covariance, the child type can be assigned to the parent type.

Therefore, when declaring the type of dispatch in Redux, we can write it like this:

interface Action {
  type: string;
}

function dispatch<T extends Action>(action: T) {

}

dispatch({
  type: "suemor",
  text:'test'
});

This way, we constrain the parameter to be a subtype of Action, which means it must have the type property, but other properties can be present or absent.

Bidirectional Covariance#

Let's take another look at the example from the previous section:

interface Person {
    name: string;
    age: number;
} 

interface Suemor {
    name: string;
    age: number;
    hobbies: string[]
}

let person: Person = { // parent
    name: '',
    age: 20
};
let suemor: Suemor = { // child
    name: 'suemor',
    age: 20,
    hobbies: ['play game', 'coding']
};

// Correct
person = suemor;
// Error -> Setting bidirectional covariance can avoid this error
suemor = person;

The error suemor = person can be resolved by setting strictFunctionTypes: false in tsconfig.json or disabling strict mode. In this case, the parent type can be assigned to the child type, and the child type can be assigned to the parent type. This situation is called bidirectional covariance.

However, this is obviously problematic and cannot guarantee type safety. Therefore, we generally enable strict mode to avoid bidirectional covariance.

Invariance#

Invariance is the simplest. If there is no inheritance relationship (A and B do not contain all the properties of each other), it is invariance. Therefore, if the types are different, an error will occur:

interface Person {
  name: string;
  age: number;
}

interface Suemor {
  name: string;
  sex:boolean
}

let person: Person = {
  name: "",
  age: 20,
};

let suemor: Suemor = {
  name: 'suemor',
  sex:true
};

// Error
person = suemor;

Contravariance#

Contravariance is a bit more difficult to understand. Let's look at the example below:

let fn1: (a: string, b: number) => void = (a, b) => {
  console.log(a);
};
let fn2: (a: string, b: number, c: boolean) => void = (a, b, c) => {
  console.log(c);
};

fn1 = fn2; // Error
fn2 = fn1; // This is allowed

You will notice that the parameters of fn1 are the parent type of fn2's parameters. Why can it be assigned to the child type?

This is contravariance. The parent type can be assigned to the child type, and function parameters have contravariant properties (while return values have covariant properties, meaning the child type can be assigned to the parent type).

As for why, if fn1 = fn2 were correct, we could only pass fn1('suemor',123), but fn1 needs to output c, which would cause problems.

Therefore, I think contravariance generally occurs when assigning between parent function parameters and child function parameters (note that this applies to functions, not function calls. This is my understanding, I'm not sure if it's correct).

Because contravariance is often used in type operations, let's look at a slightly more difficult example:

// Extract the return type
type GetReturnType<Func extends Function> = Func extends (
  ...args: unknown[]
) => infer ReturnType
  ? ReturnType
  : never;

type ReturnTypeResult = GetReturnType<(name: string) => "suemor">;

image-20230203205737963

Here, GetReturnType is used to extract the return type. The expected result for ReturnTypeResult should be "suemor", but the code above gives a result of never.

This is because function parameters follow contravariance, which means that only the parent type can be assigned to the child type. However, in this case, unknown is the parent type of {name: string}, which is incorrect. unknown should be changed to a child type of string, such as any or never. The correct answer is as follows:

type GetReturnType<Func extends Function> = Func extends (
  ...args: any[]
) => infer ReturnType
  ? ReturnType
  : never;

type ReturnTypeResult = GetReturnType<(name: string) => "suemor">;

image-20230203205711934

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