shaky.sh

TypeScript Covariance and Contravariance

The intersection (ha) of generics and subtypes in TypeScript.

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: