Covariant, Contravariant, and Invariant in Typescript
Sandro Maglione
Get in touch with meWeb development
27 October 2023
•11 min read
Sandro Maglione
Web development
Variance in Typescript specifies how a generic type F<T>
varies with respect to its type parameter T
:
If T extends U
, variance allows to know how F<T>
and F<U>
are related:
- Covariant:
F<T> extends F<U>
- Contravariant:
F<U> extends F<T>
- Invariant: Neither covariant nor contravariant
- Bivariant:
F<T> extends F<U>
andF<U> extends F<T>
Enough with abstract definitions. Let's see how this works in practice 👇
How variance works in Typescript
We are going to understand variance in practice using the following types:
We use a function type with a generic parameter T
.
In the example we have Animal
and Dog
, where Dog extends Animal
. Variance allows to define the relation between Getter<Animal>
and Getter<Dog>
(same for Setter
and Inv
).
If Dog extends Animal
, is it still true that Getter<Dog> extends Getter<Animal>
?
Let's see. We are going to show how variance works with a practical example 👇
Getter function
An example of Covariant is the getter function:
This function takes no parameters and returns a value of a generic type T
.
Covariant
Covariant means that when Dog extends Animal
then also Getter<Dog> extends Getter<Animal>
applies.
We can prove this by implementing a Getter<Dog>
:
We then define a function that takes a Getter<Animal>
as parameter:
Can we call withGetAnimal
with getDog
as parameter? Since Getter
is covariant, the answer is yes:
withGetAnimal
requires a function that returns an Animal
(Getter<Animal>
). Since getDog
returns a Dog
, which is an Animal
since Dog extends Animal
, then this works correctly.
Therefore since Dog extends Animal
then also Getter<Dog> extends Getter<Animal>
applies.
Contravariant
Does the opposite also apply?
If Dog extends Animal
can we conclude that Getter<Animal> extends Getter<Dog>
?
Notice how
Animal
andDog
are inverted compared to before.
Let's do the same as we did in the example above, with getAnimal
instead of getDog
:
Can we pass getAnimal
to withGetDog
? The answer is no:
withGetDog
requires a function that returns a Dog
(Getter<Dog>
). getAnimal
returns an Animal
, but Animal
is not a Dog
, since it is missing the dogStuff
property:
Therefore Dog extends Animal
does not imply Getter<Animal> extends Getter<Dog>
.
There is more.
Every week I build a new open source project, with a new language or library, and teach you how I did it, what I learned, and how you can do the same. Join me and other 600+ readers.
Setter function
An example of Contravariant instead is the setter function:
This function takes a single parameters of type T
and returns void
.
Covariant
Just as before, we define a Setter<Dog>
function:
And also a function that takes a Setter<Animal>
as parameter:
Can we pass setDog
to withSetAnimal
? The answer is no:
withSetAnimal
gives us an Animal
as parameter. However, getDog
requires a Dog
. As we said previously, Animal
is not a Dog
, since it is missing the dogStuff
property:
We cannot pass animal
to setDog
. Therefore Dog extends Animal
does not imply Setter<Dog> extends Setter<Animal>
, Setter
is not covariant.
Contravariant
We do the same inverting Dog
and Animal
, defining a setAnimal
function this time:
withSetDog
accepts setAnimal
? The answer is yes:
withSetDog
gives us a Dog
. Since setAnimal
accepts an Animal
, and since Dog
is an Animal
(Dog extends Animal
), we can pass dog
to setAnimal
.
Setter
is contravariant: Dog extends Animal
implies that Setter<Animal> extends Setter<Dog>
.
Invariance
An example of Invariance is the following:
A function that takes a parameter of type T
and returns T
.
Same as before we define a Inv<Dog>
, withAnimalInv
.
We can see that we cannot apply invDog
to withAnimalInv
:
withAnimalInv
provides an Animal
and expects a return type of Animal
. invDog
indeed returns a Dog
, which is a valid Animal
. However, invDog
also requires a Dog
as parameter, so animal
is not valid.
Therefore Inv<T>
is not covariant.
We do the same for contravariance by defining invAnimal
:
withDogInv
provides a Dog
. In this case we can pass dog
to invAnimal
since it requires an Animal
. This time instead the error comes from the return type: withDogInv
requires a Dog
as return type, but invAnimal
returns an Animal
.
Therefore Inv<T>
is not contravariant.
Since Inv<T>
is not covariant and not contravariant, we say that Int<T>
is invariant:
Dog extends Animal
does not imply thatInv<Dog> extends Inv<Animal>
Dog extends Animal
does not imply thatInv<Animal> extends Inv<Dog>
There is more.
Every week I build a new open source project, with a new language or library, and teach you how I did it, what I learned, and how you can do the same. Join me and other 600+ readers.
Variance and composition: Union and Intersection types
Variance is relevant also to understand composition in typescript:
Contravariant parameters composed as an intersection (
&
) are equivalent to covariant parameters composed together as a union (|
) for purposes of assignability
Simply put, this principle relates <A>
, <A | B>
, and <A & B>
when assigning variables:
- If I have
a = Type<A>
, can I assigna
tob: Type<A & B>
? - If I have
a = Type<A>
, can I assigna
tob: Type<A | B>
?
This assignability depends on variance.
Enough theory, let's see a concrete example to understand why this matter 👇
Union types
We are going to use Getter
, Setter
, and Inv
again:
Let's see an example for all of them.
It is possible to assign a variable of type Getter<A>
to a variable of type Getter<A | B>
.
Getter
is a function that returns the given type. A | B
means "A or B". Since Getter<A>
returns A
, its value can be assigned to A | B
.
With Setter
this assignability does not work. Setter
requires a parameter of type A | B
, but Setter<A>
provides only A
:
In the example above we provide a type of { b: string }
, but Setter<A>
expects a type of { a: number }
, which is not present. Therefore assignability does not work.
Inv
requires both input and output to be of type A | B
, so it is definitely not possible to assign A
.
Intersection types
We do the same analysis for intersection types (&
):
This time Getter<A>
cannot be assigned to Getter<A & B>
.
Getter<A & B>
returns A & B
, "A and B", but Getter<A>
provides only A
. No assignability then.
The inverse is valid also for Setter
.
Setter
requires a parameter of type A & B
, "A and B". For Setter<A>
instead a parameter of type A
is enough. Since Setter<A & B>
gets access to both A
and B
, A
is available for Setter<A>
, so this works:
Finally Inv
:
Same as before in this case, Inv<A>
cannot be assigned to Inv<A & B>
.
Assignability and Higher-Kinded Types
This principle is relevant to understand the encoding of Higher-Kinded Types in Typescript (from Effect):
In the Effect<R, E, A>
the above encoding defines how union (|
) and intersection (&
) are assignable to each generic parameter.
We are going to learn the details of Higher-Kinded Types and their encoding in Typescript in a follow up article 🔜
That's it!
You can find the full code at this Playground Link.
Now you have a good grasp of variance in Typescript and how it relates to assignability.
These are definitely more advanced concepts, not strictly necessary in your day to day work, but important to understand how Typescript works in all its aspects.
If you are interested in more advanced and beginner content of Typescript you can subscribe to the newsletter below 👇
Thanks for reading.