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.
- If a function takes an
never
is the empty set.- If a function takes a
never
argument, there's no value you can pass it.1
- If a function takes a
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.