Search code examples
typescriptstorybook

How can I implement a typed "both" function in TypeScript?


The storybook actions addon provides a convenient way to log callback invocations:

renderButton({onClick: action('onClick')});

The action call returns a function that logs the string (onClick) and the arguments with which it's invoked.

Sometimes I want to have both the action and something else:

renderButton({
  onClick: (arg1, arg2, arg3) => {
    action('onClick')(arg1, arg2, arg3);
    // ... my code
  }
});

The action call has gotten more verbose since I have to pass in all the arguments. So I've implemented a both function to let me write:

renderButton({
  onClick: both(action('onClick'), (arg1, arg2, arg3) => {
    // ... my code
  })
});

This works great until I try to add TypeScript types. Here's my implementation:

function both<T extends unknown[], This extends unknown>(
  a: (this: This, ...args: T) => void,
  b: (this: This, ...args: T) => void,
): (this: This, ...args: T) => void {
  return function(this: This, ...args: T) {
    a.apply(this, args);
    b.apply(this, args);
  };
}

This type checks and works at runtime, but depending on the context, it either results in unwanted any types or type errors. For example:

const fnWithCallback = (cb: (a: string, b: number) => void) => {};

fnWithCallback((a, b) => {
  a;  // type is string
  b;  // type is number
});

fnWithCallback(
  both(action('callback'), (a, b) => {
    a;  // type is any
    b;  // type is any
  }),
);

fnWithCallback(
  both(action('callback'), (a, b) => {
//                         ~~~~~~~~~~~
// Argument of type '(a: T[0], b: T[1]) => number' is not assignable to
// parameter of type '() => void'.
  }),
);

Is it possible to have both correctly capture argument types from the callback context? And to avoid the any types that presumably come arise from the action declaration:

export type HandlerFunction = (...args: any[]) => void;

Here's a playground link with the full example.


Solution

  • This should work and keep correct callback argument types:

    type UniversalCallback<Args extends any[]> = (...args: Args) => void;
    function both<
        Args extends any[],
        CB1 extends UniversalCallback<Args>,
        CB2 extends UniversalCallback<Args>
    >(fn1: CB1, fn2: CB2): UniversalCallback<Args> {
      return (...args:Args) => {
        fn1(...args);
        fn2(...args);
      };
    }
    

    This solution ignores this but I don't know if it's a problem for you because the examples of usage you gave didn't really use this.

    It's easy enough to extend support to passing this to callbacks:

    type UniversalCallback<T, Args extends any[]> = (this:T, ...args: Args) => void;
    function both<
        T,
        Args extends any[],
        CB1 extends UniversalCallback<T, Args>,
        CB2 extends UniversalCallback<T, Args>
    >(fn1: CB1, fn2: CB2): UniversalCallback<T, Args> {
      return function(this:T, ...args:Args) {
        fn1.apply(this, args);
        fn2.apply(this, args);
      };
    }
    

    It works perfectly in the following test:

    class A { f() {} }
    
    const fnWithCallback = (cb: (this: A, a: string, b: number) => void) => { };
    
    fnWithCallback(function(a, b) {
      a;  // type is string
      b;  // type is number
      this.f(); // works
    });
    
    fnWithCallback(
      both(function (a) {
        a; // correct type
        this.f(); // works
      }, function (a, b) {
        a; b; // correct types
        this.f(); // works
      }),
    );