Difference between Variance, Covariance, Contravariance, Bivariance and Invariance in TypeScript
Asked Answered
G

2

38

Could you please explain using small and simple TypeScript examples what is Variance, Covariance, Contravariance, Bivariance and Invariance?

Goober answered 28/2, 2021 at 14:30 Comment(3)
@jonrsharpe could you please revert your changes ?Goober
No; the question part of the post is for a question, not just a scratchpad. If you'd like to write an additional answer (what you've posted so far would be link-only, though), do so as an answer.Burley
@Burley ahh, ok, you are right, but I am too lazy to copy these links. For interested persons: you can check edit history of this question and copy related linksGoober
P
67

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
    }
    

Playground link to code

Pannonia answered 28/2, 2021 at 16:49 Comment(3)
What an amazing mind-twisting exercise!Parvis
Hi jcalz, did you mean F<unknown> extends F<T> under Bivariance? I don't understand how the next statement logically follows otherwise.Gabriello
yes, it was a typoPannonia
S
7

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)

Try it.

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)

Try it.

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!

Try it.

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

Try it.

Successful answered 31/10, 2023 at 22:43 Comment(5)
This was great but an example or two of the concept of variance would make this a much better answer.Quiteri
I wanted to add examples at first, but that would've been basically just a copy of jcalz's answer. Since I refer to it in the beginning, consider my answer an addition to theirsSuccessful
I think for me the key would be adding intuitive examples. jcalz’s examples were highly theoretical and yours would be a complement if they were concrete like the examples in the beginning of your answer.Quiteri
Like for example, what’s a real world example of a contravariant type? I understand the concept I guess but can’t think of any real world examples.Quiteri
@Quiteri I've added some examples. Thanks for the feedbackSuccessful

© 2022 - 2024 — McMap. All rights reserved.