[Typescript 5] Intro to Variants (keyword in & out)
Covariance - producer - out - function return position - same arrow direction
Contravariance - packager - in - function param position - different arrow direction
Invariance - both producer and packager - one in function return position and another in function param position - no arrow
Bivariance - quality checker - in both function return position and function param position - no arrow
We’re writing software that controls machinery at a snack-making factory. Let’s start with a base class and two subclasses.
class Snack {
protected constructor(
public readonly petFriendly: boolean) {}
}
class Pretzel extends Snack {
constructor(public readonly salted = true) {
super(!salted)
}
}
class Cookie extends Snack {
public readonly petFriendly: false = false
constructor(
public readonly chocolateType: '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
Cookie
s are alsoSnack
s, but not allSnack
s areCookie
s
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>
interface Producer<T> {
produce: () => T;
}
We start out with two kinds of machines
snackProducer
- which makesPretzel
s andCookies
s at randomcookieProducer
- which makes onlyCookies
let cookieProducer: Producer<Cookie> = {
produce: () => new Cookie('dark')
};
const COOKIE_TO_PRETZEL_RATIO = 0.5
let snackProducer: Producer<Snack> = {
produce: () => Math.random() > COOKIE_TO_PRETZEL_RATIO
? new Cookie("milk")
: new Pretzel(true)
};
Great! Let’s try assignments in both directions of snackProducer
and cookieProducer
snackProducer = cookieProducer // ✅
cookieProducer = snackProducer // ❌
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.
interface Producer<out T> {
produce: () => T;
}
Contravariance
Now we need to model things that package our snacks. Let’s make a Packager<T>
interface that describes packagers.
interface Packager<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!
let cookiePackager: Packager<Cookie> = {
package(item: Cookie) {}
};
let snackPackager: Packager<Snack> = {
package(item: Snack) {
if (item instanceof Cookie ) {
/* Package cookie */
} else if (item instanceof Pretzel) {
/* Package pretzel */
} else {
/* Package other snacks? */
}
}
};
Check the assigement:
cookiePackager = snackPackager // ✅
snackPackager = cookiePackager // ❌
If we need to package a bunch of Cookie
s, our fancy snackPackager
will certainly do the job. However, if we have a mix of Pretzel
s, Cookie
s and other Snack
s, 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.
interface Packager<in T> {
package: (item: T) => void;
}
Invariance
What happens if we merge these Producer<T>
and Packager<T>
interfaces together?
interface ProducerPackager<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
let cookieProducerPackager: ProducerPackager<Cookie> = {
produce() {
return new Cookie('dark')
},
package(arg: Cookie) {}
}
let snackProducerPackager: ProducerPackager<Snack> = {
produce() {
return Math.random() > 0.5
? new Cookie("milk")
: new Pretzel(true)
},
package(item: Snack) {
if (item instanceof Cookie ) {
/* Package cookie */
} else if (item instanceof Pretzel) {
/* Package pretzel */
} else {
/* Package other snacks? */
}
}
}
Check the assignement:
snackProducerPackager= cookieProducerPackager // ❌
cookieProducerPackager = snackProducerPackager // ❌
Looks like assignment fails in both directions.
- The first one fails because the
package
types 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.
function cookieQualityCheck(cookie: Cookie): boolean {
return Math.random() > 0.1
}
function snackQualityCheck(snack: Snack): boolean {
if (snack instanceof Cookie) return cookieQualityCheck(snack)
else return Math.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>
// 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.
type PrepareFoodPackage<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
// Prepare a bunch of snacks for shipment
let prepareSnacks: PrepareFoodPackage<Snack> =
(uncheckedItems, callback) => uncheckedItems.filter(callback)
// Prepare a bunch of cookies for shipment
let prepareCookies: PrepareFoodPackage<Cookie> =
(uncheckedItems, callback) => uncheckedItems.filter(callback)
Finally, let’s examine type-equivalence in both directions
// NOTE: strictFunctionTypes = false
const cookies = [
new Cookie('dark'),
new Cookie('milk'),
new Cookie('white')
]
const snacks = [
new Pretzel(true),
new Cookie('milk'),
new Cookie('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).
// NOTE: strictFunctionTypes = true
prepareSnacks (cookies, cookieQualityCheck) // ❌
prepareSnacks (snacks, cookieQualityCheck) // ❌
prepareCookies(cookies, snackQualityCheck) // ✅
More plesase read https://www.typescript-training.com/course/intermediate-v2/11-covariance-contravariance/#what-variance-helpers-do-for-you