Let’s imagine the following situation
We’re writing software that controls machinery at a snack-making factory. Let’s start with a base class and two subclasses
tsTryclassSnack {protected constructor(public readonlypetFriendly : boolean) {}}classPretzel extendsSnack {constructor(public readonlysalted = true) {super(!salted )}}classCookie extendsSnack {public readonlypetFriendly : false = falseconstructor(public readonlychocolateType : 'dark' | 'milk' | 'white') {super(false)}}
The object oriented inheritance at play makes it pretty easy to understand which of these is a subtype of the other. Cookie is a subtype of Snack, or in other words
All
Cookies are alsoSnacks, but not allSnacks areCookies
Covariance
Our factory needs to model machines that produce these items. We plan for there to be many types of snacks, so we should build a generalized abstraction for a Producer<T>
tsTryinterfaceProducer <T > {produce : () =>T ;}
We start out with two kinds of machines
snackProducer- which makesPretzels andCookiess at randomcookieProducer- which makes onlyCookies
tsTryletcookieProducer :Producer <Cookie > = {produce : () => newCookie ('dark')};constCOOKIE_TO_PRETZEL_RATIO = 0.5letsnackProducer :Producer <Snack > = {produce : () =>Math .random () >COOKIE_TO_PRETZEL_RATIO ? newCookie ("milk"): newPretzel (true)};
Great! Let’s try assignments in both directions of snackProducer and cookieProducer
tsTrysnackProducer =cookieProducer // ✅Type 'Producer<Snack>' is not assignable to type 'Producer<Cookie>'. Property 'chocolateType' is missing in type 'Snack' but required in type 'Cookie'.2322Type 'Producer<Snack>' is not assignable to type 'Producer<Cookie>'. Property 'chocolateType' is missing in type 'Snack' but required in type 'Cookie'.= cookieProducer snackProducer // ❌
Interesting! We can see that if we need a snackProducer, a cookieProducer will certainly meet our need, but if we must have a cookieProducer we can’t be sure that any snackProducer will suffice.
| Cookie | direction | Snack |
|---|---|---|
Cookie |
--- is a ---> | Snack |
Producer<Cookie> |
--- is a ---> | Producer<Snack> |
Because both of these arrows flow in the same direction, we would say
Producer<T>is covariant onT
TypeScript 5 gives us the ability to state that we intend Producer<T> to be (and remain) covariant on T using the out keyword before the typeParam.
tsTryinterfaceProducer <outT > {produce : () =>T ;}
Contravariance
Now we need to model things that package our snacks. Let’s make a Packager<T> interface that describes packagers.
tsTryinterfacePackager <T > {package : (item :T ) => void;}
Let’s imagine we have two kinds of machines
cookiePackager- a cheaper machine that only is suitable for packaging cookiessnackPackager- a more expensive machine that not only packages cookies properly, but it can package pretzels and other snacks too!
tsTryletcookiePackager :Packager <Cookie > = {package (item :Cookie ) {}};letsnackPackager :Packager <Snack > = {package (item :Snack ) {if (item instanceofCookie ) {/* Package cookie */} else if (item instanceofPretzel ) {/* Package pretzel */} else {/* Package other snacks? */}}};cookiePackager =snackPackager ;Type 'Packager<Cookie>' is not assignable to type 'Packager<Snack>'. Property 'chocolateType' is missing in type 'Snack' but required in type 'Cookie'.2322Type 'Packager<Cookie>' is not assignable to type 'Packager<Snack>'. Property 'chocolateType' is missing in type 'Snack' but required in type 'Cookie'.= snackPackager cookiePackager
If we need to package a bunch of Cookies, our fancy snackPackager will certainly do the job. However, if we have a mix of Pretzels, Cookies and other Snacks, the cookiePackager machine, which only knows how to handle cookies, will not meet our needs.
Let’s build a table like we did for covariance
| Cookie | direction | Snack |
|---|---|---|
Cookie |
--- is a ---> | Snack |
Packager<Cookie> |
<--- is a --- | Packager<Snack> |
Because these arrows flow in opposite directions, we would say
Packager<T>is contravariant onT
TypeScript 5 gives us the ability to state that we intend Packager<T> to be (and remain) covariant on T using the in keyword before the typeParam.
tsTryinterfacePackager <inT > {package : (item :T ) => void;}
Invariance
What happens if we merge these Producer<T> and Packager<T> interfaces together?
tsTryinterfaceProducerPackager <T > {package : (item :T ) => void;produce : () =>T ;}
These machines have independent features that allow them to produce and package food items.
cookieProducerPackager- makes only cookies, and packages only cookiessnackProducerPackager- makes a variety of different snacks, and has the ability to package any snack
tsTryletcookieProducerPackager :ProducerPackager <Cookie > = {produce () {return newCookie ('dark')},package (arg :Cookie ) {}}letsnackProducerPackager :ProducerPackager <Snack > = {produce () {returnMath .random () > 0.5? newCookie ("milk"): newPretzel (true)},package (item :Snack ) {if (item instanceofCookie ) {/* Package cookie */} else if (item instanceofPretzel ) {/* Package pretzel */} else {/* Package other snacks? */}}}Type 'ProducerPackager<Cookie>' is not assignable to type 'ProducerPackager<Snack>'. Types of property 'package' are incompatible. Type '(item: Cookie) => void' is not assignable to type '(item: Snack) => void'. Types of parameters 'item' and 'item' are incompatible. Property 'chocolateType' is missing in type 'Snack' but required in type 'Cookie'.2322Type 'ProducerPackager<Cookie>' is not assignable to type 'ProducerPackager<Snack>'. Types of property 'package' are incompatible. Type '(item: Cookie) => void' is not assignable to type '(item: Snack) => void'. Types of parameters 'item' and 'item' are incompatible. Property 'chocolateType' is missing in type 'Snack' but required in type 'Cookie'.= snackProducerPackager cookieProducerPackager Type 'ProducerPackager<Snack>' is not assignable to type 'ProducerPackager<Cookie>'. The types returned by 'produce()' are incompatible between these types. Property 'chocolateType' is missing in type 'Snack' but required in type 'Cookie'.2322Type 'ProducerPackager<Snack>' is not assignable to type 'ProducerPackager<Cookie>'. The types returned by 'produce()' are incompatible between these types. Property 'chocolateType' is missing in type 'Snack' but required in type 'Cookie'.= cookieProducerPackager snackProducerPackager
Looks like assignment fails in both directions.
- The first one fails because the
packagetypes are not type equivalent - The second one fails because of
produce.
Where this leaves us is that ProducerPackager<T> for T = Snack and T = Cookie are not reusable in either direction — it’s as if these types (ProducerPackager<Cooke> and ProducerPackager<Snack>) are totally unrelated.
Let’s make our table one more time
| Cookie | direction | Snack |
|---|---|---|
Cookie |
--- is a ---> | Snack |
ProducerPackager<Cookie> |
x x x x x x | ProducerPackager<Snack> |
This means that
ProducerPackager<T>is invariant onT. Invariance means neither covariance nor contravariance.
Bivariance
For completeness, let’s explore one more example. Imagine we have two employees who are assigned to quality control.
One employee, represented by cookieQualityCheck is relatively new to the company. They only know how to inspect cookies.
Another employee, represented by snackQualityCheck has been with the company for a long time, and can effectively inspect any food product that the company produces.
tsTryfunctioncookieQualityCheck (cookie :Cookie ): boolean {returnMath .random () > 0.1}functionsnackQualityCheck (snack :Snack ): boolean {if (snack instanceofCookie ) returncookieQualityCheck (snack )else returnMath .random () > 0.16 // pretzel case}
We can see that the snackQualityCheck even calls cookieQualityCheck. It can do everything cookieQualityCheck can do and more.
Our quality control employees go through a process where they check some quantity of food products, and then put them into the appropriate packaging machines we discussed above.
Let’s represent this part of our process as a function which takes a bunch of uncheckedItems and a qualityCheck callback as arguments. This function returns a bunch of inspected food products (with those that didn’t pass inspection removed).
We’ll call this function PrepareFoodPackage<T>
tsTry// A function type for preparing a bunch of food items// for shipment. The function must be passed a callback// that will be used to check the quality of each item.typePrepareFoodPackage <T > = (uncheckedItems :T [],qualityCheck : (arg :T ) => boolean) =>T []
Let’s create two of these PrepareFoodPackage functions
prepareSnacks- Can prepare a bunch of different snacks for shipmentprepareCookies- Can prepare only a bunch of cookies for shipment
tsTry// Prepare a bunch of snacks for shipmentletprepareSnacks :PrepareFoodPackage <Snack > =(uncheckedItems ,callback ) =>uncheckedItems .filter (callback )// Prepare a bunch of cookies for shipmentletprepareCookies :PrepareFoodPackage <Cookie > =(uncheckedItems ,callback ) =>uncheckedItems .filter (callback )
Finally, let’s examine type-equivalence in both directions
tsTry// NOTE: strictFunctionTypes = falseconstcookies = [newCookie ('dark'),newCookie ('milk'),newCookie ('white')]constsnacks = [newPretzel (true),newCookie ('milk'),newCookie ('white')]prepareSnacks (cookies ,cookieQualityCheck )prepareSnacks (snacks ,cookieQualityCheck )prepareCookies (cookies ,snackQualityCheck )
In this example, we can see that cookieCallback and snackCallback seem to be interchangeable. This is because, in the code snippet above, we had the strictFunctionTypes option in our tsconfig.json turned off.
Let’s look at what we’d see if we left this option turned on (recommended).
tsTry// NOTE: strictFunctionTypes = trueArgument of type '(cookie: Cookie) => boolean' is not assignable to parameter of type '(arg: Snack) => boolean'. Types of parameters 'cookie' and 'arg' are incompatible. Property 'chocolateType' is missing in type 'Snack' but required in type 'Cookie'.2345Argument of type '(cookie: Cookie) => boolean' is not assignable to parameter of type '(arg: Snack) => boolean'. Types of parameters 'cookie' and 'arg' are incompatible. Property 'chocolateType' is missing in type 'Snack' but required in type 'Cookie'.prepareSnacks (cookies ,) cookieQualityCheck Argument of type '(cookie: Cookie) => boolean' is not assignable to parameter of type '(arg: Snack) => boolean'. Types of parameters 'cookie' and 'arg' are incompatible. Property 'chocolateType' is missing in type 'Snack' but required in type 'Cookie'.2345Argument of type '(cookie: Cookie) => boolean' is not assignable to parameter of type '(arg: Snack) => boolean'. Types of parameters 'cookie' and 'arg' are incompatible. Property 'chocolateType' is missing in type 'Snack' but required in type 'Cookie'.prepareSnacks (snacks ,) cookieQualityCheck prepareCookies (cookies ,snackQualityCheck )
What variance helpers do for you
There are two reasons to use variance helpers in your code
- If you have recursive types in your project, these hints allow TypeScript to type-check significantly faster. Behinds the scenes, the compiler gets to skip a bunch of work, if it knows that a typeParam is purely
inorout. - It allows you to encode more of your intent, and (where useful) catch any changes to variance in the interface declaration instead of at the places where the interface is used.
Here’s a comparison of the error experiences, with and without the variance helpers.
Here’s our working state for Packager<T> again
tsTryinterfacePackager <T > {package : (item :T ) => void;}letsnackPackager !:Packager <Snack >letcookiePackager :Packager <Cookie > =snackPackager
And let’s change Packager<T> so that it becomes invariant on T
tsTryinterfacePackager <T > {package : (item :T ) => void;produce : () =>T ;}letsnackPackager !:Packager <Snack >letType 'Packager<Snack>' is not assignable to type 'Packager<Cookie>'. The types returned by 'produce()' are incompatible between these types. Property 'chocolateType' is missing in type 'Snack' but required in type 'Cookie'.2322Type 'Packager<Snack>' is not assignable to type 'Packager<Cookie>'. The types returned by 'produce()' are incompatible between these types. Property 'chocolateType' is missing in type 'Snack' but required in type 'Cookie'.: cookiePackager Packager <Cookie > =snackPackager
Finally, we’ll add that in keyword
tsTryinterfaceType 'Packager<super-T>' is not assignable to type 'Packager<sub-T>' as implied by variance annotation. The types returned by 'produce()' are incompatible between these types. Type 'super-T' is not assignable to type 'sub-T'.2636Type 'Packager<super-T>' is not assignable to type 'Packager<sub-T>' as implied by variance annotation. The types returned by 'produce()' are incompatible between these types. Type 'super-T' is not assignable to type 'sub-T'.Packager <inT > {package : (item :T ) => void;produce : () =>T ;}letsnackPackager !:Packager <Snack >letcookiePackager :Packager <Cookie > =snackPackager
The error is surfaced at Packager<T>’s declaration site, and is articulated in terms of violating a variance constraint, not the resultant type-checking error that arises from the call site which requires covariance in order to compile.