EN VI

When assigning callbacks in TypeScript, parameters are assigned in the opposite direction of the callback itself. Why is that?

2024-03-12 04:00:08
When assigning callbacks in TypeScript, parameters are assigned in the opposite direction of the callback itself. Why is that?

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?

Solution:

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.

Playground link to code

Answer

Login


Forgot Your Password?

Create Account


Lost your password? Please enter your email address. You will receive a link to create a new password.

Reset Password

Back to login