Could you please explain using small and simple TypeScript examples what is Variance, Covariance, Contravariance, Bivariance and Invariance?
Variance has to do with how a generic type F<T>
varies with respect to its type parameter T
. If you know that T extends U
, then variance will tell you whether you can conclude that F<T> extends F<U>
, conclude that F<U> extends F<T>
, or neither, or both.
Covariance means that F<T>
and T
co-vary. That is, F<T>
varies with (in the same direction as) T
. In other words, if T extends U
, then F<T> extends F<U>
. Example:
Function or method types co-vary with their return types:
type Co<V> = () => V; function covariance<U, T extends U>(t: T, u: U, coT: Co<T>, coU: Co<U>) { u = t; // okay t = u; // error! coU = coT; // okay coT = coU; // error! }
Other (un-illustrated for now) examples are:
- objects are covariant in their property types, even though this not sound for mutable properties
- class constructors are covariant in their instance types
Contravariance means that F<T>
and T
contra-vary. That is, F<T>
varies counter to (in the opposite direction from) T
. In other words, if T extends U
, then F<U> extends F<T>
. Example:
Function types contra-vary with their parameter types (with
--strictFunctionTypes
enabled):type Contra<V> = (v: V) => void; function contravariance<U, T extends U>(t: T, u: U, contraT: Contra<T>, contraU: Contra<U>) { u = t; // okay t = u; // error! contraU = contraT; // error! contraT = contraU; // okay }
Other (un-illustrated for now) examples are:
- objects are contravariant in their key types
- class constructors are contravariant in their construct parameter types
Invariance means that F<T>
neither varies with nor against T
: F<T>
is neither covariant nor contravariant in T
. This is actually what happens in the most general case. Covariance and contravariance are "fragile" in that when you combine covariant and contravariant type functions, its easy to produce invariant results. Example:
Function types that return the same type as their parameter neither co-vary nor contra-vary in that type:
type In<V> = (v: V) => V; function invariance<U, T extends U>(t: T, u: U, inT: In<T>, inU: In<U>) { u = t; // okay t = u; // error! inU = inT; // error! inT = inU; // error! }
Bivariance means that F<T>
varies both with and against T
: F<T>
is both covariant nor contravariant in T
. In a sound type system, this essentially never happens for any non-trivial type function. You can demonstrate that only a constant type function like type F<T> = string
is truly bivariant (quick sketch: T extends unknown
is true for all T
, so F<T> extends F<unknown>
and F<unknown> extends F<T>
, and in a sound type system if A extends B
and B extends A
, then A
is the same as B
. So if F<T>
= F<unknown>
for all T
, then F<T>
is constant).
But Typescript does not have nor does it intend to have a fully sound type system. And there is one notable case where TypeScript treats a type function as bivariant:
Method types both co-vary and contra-vary with their parameter types (this also happens with all function types with
--strictFunctionTypes
disabled):type Bi<V> = { foo(v: V): void }; function bivariance<U, T extends U>(t: T, u: U, biT: Bi<T>, biU: Bi<U>) { u = t; // okay t = u; // error! biU = biT; // okay biT = biU; // okay }
F<unknown> extends F<T>
under Bivariance? I don't understand how the next statement logically follows otherwise. –
Gabriello A.S.: jcalz's answer is great from the technical perspective. I'd like to add some intuition to it.
When is variance relevant?
Variance becomes relevant when dealing with two types that are related, but not exactly identical to each other.
For example, here both value1
and value2
are of the same type — number
, and so one is always assignable to the other and vice versa, without any errors:
declare let value1: number
declare let value2: number
value1 = value2 // no error
value2 = value1 // no error
Also, here value1
is a number
, while value2
is a string
. These types are completely unrelated to each other, and so assigning one to the other is always an error:
declare let value1: number
declare let value2: string
value1 = value2 // Error!
value2 = value1 // Error!
In both cases, changing the assignment direction (value2
to value1
or vice versa) doesn't change the result: it's always no error in the first case, and always an error in the second.
Variance becomes relevant when the assignability direction matters
Let's see what happens when the order of assignment matters, i.e., assigning one value to the other is fine, but it doesn't work in reverse.
Here, the type Person
only has a property name
, while the type Student
also has additional graduationYear
. That is, Student
contains everything from Person
, while Person
covers Student
only partially.
Assigning a person to a student is an error, because Person
only has name
but a student is expected to also have graduationYear
. However, assigning a student to a person works just fine, since all students have a name, just like any person (regardless of whether it has any other properties):
type Person = { name: string }
type Student = { name: string, graduationYear: number }
declare let person: Person // for example: { name: 'Mike' }
declare let student: Student // for example: { name: 'Sofia', graduationYear: 2020 }
person = student // no error
student = person // Error!
This isn't variance yet, but just assignability (i.e., type compatibility). Variance is a more complex concept built on top of it.
So, what is variance?
Variance is a measure of how the assignability between instances of a given generic correlates with the assignability between instances of its type parameters.
…
Okay, let's unpack that.
A generic is a type that is defined through another type. A good example of a generic is Array
: Array<number>
is not the same as Array<string>
, even though both are arrays. The number
in Array<number>
and the string
in Array<string>
are type parameters (or type arguments) of a generic type Array<…>
.
The question is, if I have Array<A>
and Array<B>
, which can be assigned to which?
That's variance.
Given the assignability between A
and B
, variance specifies the assignability between F<A>
and F<B>
(where F<…>
is a generic).
What are the options of variance?
There are four possible ways of changing the original assignability direction:
It may be helpful (if slightly creepy) to compare this to blood types: type A contains anti-B antibodies; type B contains anti-A antibodies; type AB contains no antibodies; finally, type O contains both antibodies.
Covariance
Wrapping a type in a generic keeps the assignability direction as it is; i.e., the generic varies in the same direction as (it co-varies with) its type argument. Arrays are covariant with the type of their item, which means that you can use Array<Student>
where Array<Person>
is expected:
declare function printNames(persons: Person[]): void
declare const students: Student[]
printNames(students)
Contravariance
Wrapping a type in a generic reverses the direction; i.e., the generic varies in the opposite direction as (it contra-varies with) its type argument. Functions are contravariant with the type of their arguments, which means that you can use (person: Person) => any
where (student: Student) => any
is expected:
declare function printName(person: Person): void
declare function forEachStudent(callback: (student: Student) => void): void
forEachStudent(printName)
This behaviour is only observed with --strictFunctionTypes=true
compiler option; see Bivariance section below for details.
Invariance
Wrapping a type in a generic disallows both directions; i.e., there is no variance, and if a value F<T>
is required, providing anything else is a compiler error.
AFAIK, there are no explicitly invariant things in TypeScript, but functions that have the same return type as their argument are invariant by construction. Their contravariance with the argument eliminates variance in one direction, and their covariance with the return type eliminates variance in the other direction, and that essentially makes them "lock" on the exact type. This means that you can't even provide a no-op function doNothing
here because it doesn't have the exact same signature:
declare function incrementCurrentGrade(student: Student): Student
declare function updateAllStudents(updateStudent: (student: Student) => Student): void
updateAllStudents(incrementCurrentGrade)
function doNothing(person: Person): Person {
return person
}
updateAllStudents(doNothing) // Error!
Bivariance
Wrapping a type in a generic allows both directions, i.e., all super- and derived types are allowed in place of a given type. If you think about it, it basically means anything is allowed, which sounds like it defeats the purpose of having strict typing system, – and bivariance is indeed the "loosest" option (in the same way in which invariance is the "strictest" one).
Historically (before this PR), all functions and methods in TypeScript were bivariant with their argument types, meaning that you basically could use any function in place of any other function. The PR had introduced --strictFunctionTypes
compiler option which changed bivariance to contravariance for functions (see Contravariance section above for details) – but only for functions; methods were (and still are) chosen to remain bivariant, with or without that compiler option. Which means that you can use { foo(person: Person): void }
and { foo(student: Student): void }
interchangeably.
declare function addStudentToTwoSets(
somePersons: { add(person: Person): void },
someStudents: { add(student: Student): void },
): void
declare const persons: { add(person: Person): void }
declare const students: { add(student: Student): void }
addStudentToTwoSets(persons, students) // no error
addStudentToTwoSets(students, persons) // no error
© 2022 - 2024 — McMap. All rights reserved.