TypeScript Covariance and Contravariance
I'll admit: this is a pretty intensely nerdy corner of TypeScript, and one that I haven't yet found a use for in a production system. Here's hoping that one day I run into a contravariance problem and know exactly what to do because I stumbled across this bit of syntax I formerly knew nothing about.
(I've made a video about this topic if watching is more your thing.)
A bit of set theory
TypeScript is structurally-typed, which means that for a value to be of a type, it only needs to fulfil the requirements of that type. It doesn't need to be created with a particular constructor, as is the case with many strongly-typed languages.
An example: this jobName
variable can be treated as all of the
following types:
const jobName = "send-email";
type JobName1 = string;
type JobName2 = "send-email" | "fulfil-order" | "publish-report";
type JobName3 = `${string}-email`;
type JobName4 = "send-email";
These types get narrower as we go down, with string
being the broadest, and "send-email"
being the most narrow (only matching a single value).
Of course, in set theory these are called subsets: a union of string literals is a subset of all string values. In programing languages, we usually call these subtypes. For many languages, subtypes are only create via inheritance, but because of the shape-based nature of TypeScript, we don't need the explicit relationship between these types.
With this understanding, we can take a look at covariance and contravariance.
co-what-now?
Covariance and contravariance describe how subtype relationship operate inside types with type arguments; that is inside generics.
Let's say we have these two types in our job queuing system, as well as a function for executing jobs.
type Job = {
queueName: string;
enqueuedAt: Date;
transactionId: string;
name: string;
};
type PriorityJob = Job & {
priority: true,
level: 1 | 2 | 3
};
function executeJob(job: Job) {
// ...
};
This function will accept both Job
objects and PriorityJob
objects, because
PriorityJob
is sub-type of Job
. So far, so normal.
declare function executeJob(job: Job): void;
declare const j1: Job;
declare const j2: PriorityJob;
executeJob(j1);
executeJob(j2);
But what about a function that takes an array of jobs?
function executeJobs(job: Array<Job>) {
// ...
};
Will this function accept an Array<PriorityJob>
argument? It will!
declare function executeJobs(jobs: Array<Job>): void;
declare const j3: Array<Job>;
declare const j4: Array<PriorityJob>;
executeJobs(j3);
executeJobs(j4);
More generally,
if B
extends/intersects/is-a-subtype-of A
, then Array<B>
also
extends/intersects/is-a-subtype-of Array<A>
. That's covariance: Array
is considered
covariant on T
.
Covariance is the behaviour you get on most generic types, and certainly on most of
the built-in generics: Promise
, Omit
, ReadonlyArray
, Map
, and so on. Any custom wrapper
or envelope type with a generic argument T
will likely be covariant over T
.
Contravariance
But what if our job queue system wants to use custom job executor functions? The type might look something like this:
type Executor<J> = (j: J) => 'success' | 'failure';
If we create an registerExecutor
function that takes an Executor<Job>
, does covariance
still hold? Let's find out:
declare function registerExecutor(e: Executor<Job>): void;
declare const e1: Executor<Job>;
declare const e2: Executor<PriorityJob>;
registerExecutor(e1);
registerExecutor(e2); // error!
It doesn't work! Executor<PrimaryJob>
doesn't meet the requirements of Executor<Job>
.
Here's the error we get:
Argument of type 'PriorityJobExecutor' is not assignable to parameter of type 'Executor'.
Types of parameters 'j' and 'j' are incompatible.
Type 'Job' is not assignable to type 'PriorityJob'.
Type 'Job' is missing the following properties from type '{ priority: true; level: 1 | 2 | 3; }': priority, level(2345)
What's happening is this: our registerExecutor
function expects to receive a function that can take a Job
as an argument.
A function that takes a PriorityJob
doesn't meet that requirement, because it expects to receive a narrower type
than Job
. If we're going to be passing Job
instances to this function, is can't require something more specific
(PriorityJob
), right?
What's interesting, though, is that the following works. Notice the signature of registerPriorityExecutor
:
declare function registerPriorityExecutor(e: Executor<PriorityJob>): void;
declare const e1: Executor<Job>;
declare const e2: Executor<PriorityJob>;
registerPriorityExecutor(e1);
registerPriorityExecutor(e2);
If registerPriorityExecutor
expects to receive a function that takes a PriorityJob
, then it can also
receive a function that takes a Job
. How so? Well, every PriorityJob
is also a Job
, so a function
that can process a Job
can also process a PriorityJob
.
All this means that our Executor
type is contravariant on J
: it reverses the subtype
order: Executor<Job>
is now a subtype of Executor<PriorityJob>
.
Inputs and Outputs
So all function types with a generic parameter are contravariant on that parameter? Not exactly. It depends on whether it's an argument or a return type that's generic: if it's an input or an output. The rule is this:
- Input types (that is, argument types) are covariant.
- Output types (that is, return types) are contravariant.
Mostly, TypeScript infers this correctly, but if you want to be explicit,
the in
and out
modifiers were
introduced in v4.7
for exactly this.
To demonstrate, let's say we have a queue for managing jobs:
type Queue<J> = {
enqueue(j: J): void;
getJob(id: string): J;
};
We want to use J
as both an input and an output, so we can explicitly declare this with like so:
type Queue<in out J> = {
enqueue(j: J): void;
getJob(id: string): J;
};
Play around with the various permutations of this to get familiar with it, but in this case, with both
in
and out
modifiers declared, TypeScript will never accept a subtype or supertype of J
. So, we'll
never be able to assign a Queue<Job>
to a Queue<PriorityJob>
, or vice versa.
More Info
A few links that helped me wrap my head around this topic: