I also recently came into contact with this knowledge, and there may be some errors in the article. I hope the experts can provide more guidance. (
It is important to understand the concepts of contravariance, covariance, bidirectional covariance, and invariance when learning TypeScript and understanding 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 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, so A often has more properties than B, so A is the subtype. Or you can remember one characteristic: subtypes are more specific than supertypes.
Also, 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 everyday 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 later in bidirectional covariance.
suemor = person;
Although these two types are different, suemor can be assigned to person, which means that a child type can be assigned to a parent type, but not vice versa (think about what would happen if person could be assigned to suemor and you called suemor.hobbies
in your program).
Therefore, the conclusion is: when a subtype can be assigned to a supertype, it is called covariance.
Application in functions#
Covariance can also be applied 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 an additional piece of knowledge (because I made a mistake when I learned it), the hobbies here will cause an error because it is directly assigned without type inference.
hobbies: ["play game", "coding"]
})
Here, we have added an additional hobbies property. Similarly, because of covariance, a child type can be assigned to a 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, the parameter passed to dispatch
must be a subtype of Action
, which means it must have the type
property, but other properties can be present or not.
Bidirectional covariance#
Let's take a 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
to 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, so we generally keep strict mode enabled to avoid bidirectional covariance.
Invariance#
Invariance is the simplest. If there is no inheritance relationship (A and B do not have 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 the parameters of a function have contravariant properties (while the return value has covariant properties, which means the child type can be assigned to the parent type).
As for why, if fn1 = fn2
is correct, we can only pass fn1('suemor',123)
, but fn1
needs to output c
, which would cause issues.
Therefore, I feel that contravariance generally occurs when assigning between parent function parameters and child function parameters (note that this is between functions, not when calling functions, this is how I understand it, 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">;
Here, GetReturnType
is used to extract the return type. The expected result of 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, it is clear that unknown
is the parent type of {name: string}
, so it is reversed. unknown
should be changed to 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">;
Here, unknown
has been changed to any
or never
, and the result is correct.