shaky.sh

Contravariance? Never!

Yesterday some colleagues and I were discussing the relationship between any, unknown, and never in TypeScript. The mental model that's been helpful for me is Set Theory:

  • unknown is the set of all possible values.
    • If a function takes an unknown argument, you can pass it any value.
  • never is the empty set.
    • If a function takes a never argument, there's no value you can pass it.1
  • any does not conform to set theory.
    • It's the equivalent of the compiler throwing up its hands and saying "okay, fine, you drive!"

An Example

The catalysts of our converstaion were the Parameters and ReturnType utility types built into TypeScript. If you aren't familiar, here they are as defined in es5.d.ts in the TypeScript source

type Parameters<T extends (...args: any) => any> = T extends (...args: infer P) => any ? P : never;

type ReturnType<T extends (...args: any) => any> = T extends (...args: any) => infer R ? R : any;

The unknown type was introduced in TS 3.0 as a type-safe version of any, so it seemed weird to me that these core utility types are still using any when there are better options. Can't we just replace any with unknonwn in Parameters and ReturnTypes and move on?

Actually, no. Try it, and you'll see that something is wrong:

type ParametersNoAny<T extends (...args: unknown[]) => unknown> = T extends (...args: infer P) => unknown ? P : never;

type ReturnTypeNoAny<T extends (...args: unknown[]) => unknown> = T extends (...args: unknown[]) => infer R ? R : unknown;

function testFn(a1: string, a2: number): boolean {
  return parseInt(a1, 10) === a2;
}

type Args = ParametersNoAny<typeof testFn>; // error!
type Ret = ReturnTypeNoAny<typeof testFn>; // error!

When we try to pass typeof testFn to either of our new utilities, we get this type error:

Type '(a1: string, a2: number) => boolean' does not satisfy the constraint '(...args: unknown[]) => unknown'.
  Types of parameters 'a1' and 'args' are incompatible.
    Type 'unknown' is not assignable to type 'string'.

When I first saw this error, it seemed backwards to me. I don't want to assign unknown to string, I want to go the other way, right? I'm passing my string-arged function into these utilities, so I'm trying to assign string where the constraint is unknown, which should accept everything, right? Right?!?

Actually, this is a situation where it's helpful to understand contravariance. I wrote a post about covariance and contravariance in TypeScript last year, but I still don't feel like I have a really good grasp of when it's relevant or even in play. But let's make an attempt, using the real-world example of packing boxes.

What kind of box do you want?

Let's think of ParametersNoAny and ReturnTypeNoAny as you when helping a friend move, and generic argument as the boxes you pack their stuff into.

The constraint on the generic parameter (T extends ...) is a request for a box that fits a specific item. And right now, that item is unknown[]: in other words, you want a box that can fit any number of any items. In our terms, that's the set of all possible sets of arguments. There are no functions2 in the set of "functions that accept all possible sets of arguments"! No wonder we get an error when we pass in a function that only accepts a string and a number.

The solution here is to say that these functions should take never[] arguments:

type ParametersNoAny<T extends (...args: never[]) => unknown> = T extends (...args: infer P) => unknown ? P : never;

type ReturnTypeNoAny<T extends (...args: never[]) => unknown> = T extends (...args: never[]) => infer R ? R : unknown;

Now, we are asking for a box that can fit no items. Not a box that must fit no items, but a box that can. To put it another way, you want a box that doesn't need to work for a specific item or set of items: any box will do. Your friend can pass you a box that fits a toaster, or a screwdriver, or a set of tires, and you'll be happy with whatever it is.

It works!

With these changes, our utility types now work! No errors, and they correctly infer the types we want.

type Args = ParametersNoAny<typeof testFn>; // [a1: string, a2: number]
type Ret = ReturnTypeNoAny<typeof testFn>; // boolean

So glad I finally understand contravariance ... for this week.


  1. and yet you must pass it a variable. But that's a different post. ↩︎

  2. expect functions that take (...args: unknown[]), I guess. ↩︎