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
withCallback((x: string, f: (x: string) => number) => f(x))andwithCallback((x: string) => x.length). Maybe you need to restrictfto a function whose last argument cannot be a function