When calling a function with an argument, the argument type is assigned to the parameter type. In other words, the argument type must be equal to, or narrower than, the parameter type.
Example:
const foo = (bar: string | number) => {
console.log(bar);
}
foo('bar');
The above works fine because 'bar'
(a string literal type) is assignable to string | number
.
Now, consider a callback scenario instead:
const foo = (bar: (baz: string | number) => void) => {
bar('baz');
// We should probably also have a call with a numeric parameter here, like bar(42),
// since the callback signature implies that it is also called that way.
}
const bar = (baz: string) => {}
foo(bar);
We get this error message on bar('baz')
:
Argument of type '(baz: string) => void' is not assignable to parameter of type '(baz: string | number) => void'.
Types of parameters 'baz' and 'baz' are incompatible.
Type 'string | number' is not assignable to type 'string'.
Type 'number' is not assignable to type 'string'.ts(2345)
As seen, in the callback case, TS attempts to assign type (baz: string) => void
(the argument type) to (baz: string | number) => void
(the parameter type). That was as expected.
The parameter of the callback, however, is assigned in the opposite direction: Type string | number
(parameter of callback argument) is assigned to type string
(parameter of callback parameter).
How come the assignment direction of the parameters in the callback are inverted from the non-callback scenario?
If you see that assignments go "in the opposite direction", you could say they "counter-vary". Indeed, function types are contravariant in their input types. So a function of the form (x: X) => void
is assignable to (y: Y) => void
if Y
is assignable to X
, not vice versa. In particular, (baz: string | number) => void
is assignable to (baz: string) => void
, but (baz: string) => void
is not assignable to (baz: string | number) => void
.
This can be seen by adding more functionality to your code that conforms to the types. First, inside foo()
, the bar
parameter is supposed to accept string | number
, so you can call bar()
with any string or numeric argument you want. Let's make sure we do both:
const foo = (bar: (baz: string | number) => void) => {
bar('baz');
bar(123); // <-- this is okay too
}
Then bar
accepts only a baz
argument of type string
, meaning that it should be fine to treat baz
as a string, like calling its toUpperCase()
method:
const bar = (baz: string) => { baz.toUpperCase() } // <-- this is okay too
Now,
foo(bar); // error
// ~~~ <-- Type 'string | number' is not assignable to type 'string'.
gives you that same compiler error (and nothing has changed from the type system's point of view). Due to contravariance, the error message is telling you that (baz: string) => void
is not assignable to (baz: string | number) => void
because string | number
is not assignable to string
.
And if you actually run this code without fixing the error, you'll get a runtime error that baz.toUpperCase
is not a function.
The fact that a function can handle a string
does not imply that it can handle a string | number
.
On the other hand, it's perfectly fine to widen the function input:
const qux = (baz: string | number | boolean) => {
if (typeof baz === "string") {
console.log(baz.toUpperCase())
} else if (typeof baz === "number") {
console.log(baz.toFixed())
} else {
console.log(baz === true)
}
}
foo(qux); // okay
That works because any function which can handle a string | number | boolean
can definitely handle a string | number
. There's no runtime error because inside qux()
one does not just write baz.toUpperCase()
without checking to see if it's a string
. (And if you tried to do that, you'd get a compiler error there saying that number
and boolean
don't have toUpperCase()
methods).
So that's why things are assigned in the opposite direction for function inputs. They counter-vary because of the direction data moves. A piece of data needs to be no wider than the box it goes into. So you can always safely make that box bigger or the data smaller, but not vice versa. For function parameters, data is coming in, meaning that the function can make the input parameter wider but not narrower.