1

What I want to achieve is the following: given a function f of random signature, let's say f: (x: string, y: number): boolean, I want to create a function g, which satisfies:

  • The signature g: (x: string, y: number, callback?: (b: boolean) => void): void
  • The value passed into the callback is the return value f(x,y)

Basically, I want to callback-ify any function, without loosing type information.

Here's my take on it:

type Callback<T> = (t: T) => void;

function withCallback<F extends(...args: any) => any>(
    f: F,
) {
    return function (...args: any) {
        let callback: Callback<ReturnType<F>> | undefined;
        let params: Array<any> = args;

        if (args.length && typeof (args[args.length - 1]) === 'function') {
            callback = args[args.length - 1];
            params = args.slice(0, args.length - 1);
        }

        const result = f(...params);
        callback(result);
    } as (...args: Parameters<F>) => void; // ???
}

function f(a: string) { return 2 };
const g = withCallback(f);

The code works: for example

console.log(f('a'));
g('a', console.log);

both will output the same thing. But I can't make the typings right. g has the correct arguments except the optional callback argument, which I can't seem to make it fit. More specifically, I don't know what type to put in the line marked with ???.

TS Playground link

9
  • What do you want to do if no callback is passed? Commented Jun 4, 2019 at 13:52
  • Nothing, if no callback is passed. Commented Jun 4, 2019 at 13:53
  • 1
    The callback really shouldn't be optional unless you'd like to deal with figuring out the difference between something like withCallback((x: string, f: (x: string) => number) => f(x)) and withCallback((x: string) => x.length). Maybe you need to restrict f to a function whose last argument cannot be a function Commented Jun 4, 2019 at 14:07
  • I have sort of a typing solution for the situation where no parameters are optional, including the callback... having optional stuff isn't really easy. Let me know if you're interested in the non-optional solution Commented Jun 4, 2019 at 14:08
  • Just FYI, adding the callback as the first argument would make things really easy and would make optional stuff work well and will even preserve parameter names, adding it at the end wherešŸ‰ lie will be ugly. Commented Jun 4, 2019 at 14:15

1 Answer 1

2

UPDATE: TypeScript 4.0 will feature variadic tuple types, which will allow more flexible built-in tuple manipulation. Push<T, V> will be simply implemented as [...T, V]. The code is here. The following is left for posterity to show how "fun" it was building Push in a pre-TS-4.0 world.


I might as well give the solution where nothing is optional (so the function f in withCallback(f) doesn't have optional parameters, and calling withCallback(f)(...args, cb) requires cb).

The issue is that you'd like to represent the effect of appending an element V to the end of a tuple type T. I'll call this operation Push<T, V>. TypeScript doesn't support that out of the box. Since the introduction of rest tuples, TypeScript does support prepending an element V onto the beginning of a tuple type T, though; I'll call this operation Cons<V, T>:

// Prepend an element V onto the beginning of a tuple T.
// Cons<1, [2,3,4]> is [1,2,3,4]
type Cons<V, T extends any[]> = ((v: V, ...t: T) => void) extends ((
  ...r: infer R
) => void)
  ? R
  : never;

You can mostly implement Push<T, V> in terms of Cons<V, T> and mapped/conditional types, as long as the elements of the tuple are not optional:

// Append an element V onto the end of a tuple T
// Push<[1,2,3],4> is [1,2,3,4]
// note that this DOES NOT PRESERVE optionality/readonly in tuples.
// So unfortunately Push<[1, 2?, 3?], 4> is [1,2|undefined,3|undefined,4]
type Push<T extends any[], V> = (Cons<any, Required<T>> extends infer R
  ? { [K in keyof R]: K extends keyof T ? T[K] : V }
  : never) extends infer P
  ? P extends any[] ? P : never
  : never;

(The problem is that Cons works by shifting elements to the right, including the optional elements... and Push will end up leaving them in their shifted locations, which isn't what you want.) Maybe someday there will be a supported way to make Push behave exactly as desired, but for now this is the best I can reasonably do.

So, armed with Push, here's how we proceed:

type Callback<T> = (t: T) => void;

function withCallback<F extends (...args: any) => any>(f: F) {
  return function(...args: any[]) {
    const params = args.slice(); // as Parameters<F>; <-- this doesn't help, unfortunately
    const callback = params.pop() as Callback<ReturnType<F>>;
    callback(f(...params));
  } as (...args: Push<Parameters<F>, Callback<ReturnType<F>>>) => void;
}

And let's see if it works:

function f(a: string) {
  return a.length;
}
const g = withCallback(f);
g("Hello", n => console.log(n - 2)); // okay, console logs 3
g("Goodbye", "oops"); // error!
//           ~~~~~~ <-- "oops" is not assignable to Callback<number>

Looks good to me. Okay, hope that helps. Good luck!

Link to code


UPDATE: Here is a possible way forward with an optional callback, prohibiting functions whose last argument is a function. You can't have optional arguments to that function, though... at least not easily.

I'll let the code speak for itself since I'm out of time. Good luck!

// Tail<T> pulls the first element off a tuple: Tail<[1,2,3]> is [2,3]
type Tail<T extends any[]> = ((...t: T) => void) extends ((
  h: any,
  ...r: infer R
) => void)
  ? R
  : never;
// Last<T> returns the last element of a tuple: Last<[1,2,3]> is 3
type Last<T extends any[]> = T[Exclude<keyof T, keyof Tail<T>>];

// OkayFunction<F> for a function F evaluates to F if the last argument
// is not a function, or never if the last argument is a function
type OkayFunction<F extends (...args: any) => any> = Last<
  Parameters<F>
> extends Function
  ? never
  : F;

// have withOptionalCallback return an overloaded function to make up for
// the lack of ability to push an optional element onto a tuple 
function withOptionalCallback<F extends (...args: any) => any>(
  f: F & OkayFunction<F>
): {
  (...args: Parameters<F>): void;
  (...args: Push<Parameters<F>, Callback<ReturnType<F>>>): void;
};
function withOptionalCallback(f: Function) {
  return function(...args: any[]) {
    const params = args.slice();
    let cb: Function = () => {};
    if (typeof params[params.length - 1] === "function") {
      cb = params.pop();
    }
    cb(f(...params));
  };
}

// make sure it works
const g2 = withOptionalCallback(f);
g2("Hello"); // okay
g2(123); // error, 123 is not a string

g2("Hello", n=>console.log(n-2)); // okay
g2("Hello", "oops"); // error, "oops" is not a Callback<number>;

function badF(x: string, y: (x: string)=>number) { return y(x); }
const cantDoThis = withOptionalCallback(badF) // error!
// Argument of type '(x: string, y: (x: string) => number) => number' 
// is not assignable to parameter of type 'never'.

Link to new code

Sign up to request clarification or add additional context in comments.

Comments

Start asking to get answers

Find the answer to your question by asking.

Ask question

Explore related questions

See similar questions with these tags.