Typescript function overloads with generic optional parameters

Viewed 7349

I'm trying to write a higher-order function wraps the input function and caches the result of the most recent call as a side effect. The basic function (withCache) looks something like this:

function cache(key: string, value: any) {
    //Some caching logic goes here
}

function withCache<R>(key: string, fn: (...args: any[]) => R): (...args: any[]) => R {
    return (...args) => {
        const res = fn(...args);
        cache(key, res);
        return res;
    }
}

const foo = (x: number, y: number) => x + y;
const fooWithCache = withCache("foo", foo);
let fooResult1 = fooWithCache(1, 2); // allowed :)
let fooResult2 = fooWithCache(1, 2, 3, 4, 5, 6) // also allowed :(

Now I know I can make this type safe - up to a point - using function overloads, like so:

function withCache<R>(key: string, fn: () => R): () => R
function withCache<R, T1>(key: string, fn: (a: T1) => R): (a: T1) => R
function withCache<R, T1, T2>(key: string, fn: (a: T1, b: T2) => R): (a: T1, b: T2) => R
function withCache<R>(key: string, fn: (...args: any[]) => R): (...args: any[]) => R {
    // implementation ...
}

const foo = (x: number, y: number) => x + y;
const fooWithCache = withCache("foo", foo);
let fooResult1 = fooWithCache(1, 2); // allowed :)
let fooResult2 = fooWithCache(1, 2, 3, 4, 5, 6) // not allowed :)

The trouble comes when I try to allow functions with optional arguments (the last overload is new):

function withCache<R>(key: string, fn: () => R): () => R
function withCache<R, T1>(key: string, fn: (a: T1) => R): (a: T1) => R
function withCache<R, T1, T2>(key: string, fn: (a: T1, b: T2) => R): (a: T1, b: T2) => R
function withCache<R, T1, T2>(key: string, fn: (a: T1, b?: T2) => R): (a: T1, b?: T2) => R
function withCache<R>(key: string, fn: (...args: any[]) => R): (...args: any[]) => R {
    // implementation ...
}

const foo = (x: number, y?: number) => x + (y || 0);
const fooWithCache = withCache("foo", foo);
let fooResult1 = fooWithCache(1); // allowed :)
let fooResult2 = fooWithCache(1, 2) // not allowed, but should be :(

The problem seems to be that Typescript is picking the wrong overload for withCache, and the result is that the signature for fooWithCache is (a: number) => number. I would expect fooWithCache's signature to be (a: number, b?: number) => number, just like foo. Is there any way to fix this?

(As a side note, is there any way to declare the overloads so I don't have to repeat each overload's function type (...) => R?)

Edit:

Figured out my secondary question about not repeating the function type: just define it!

type Function1<T1, R> = (a: T1) => R;
// ...
function withCache<T1, R>(fn: Function1<T1, R>): Function1<T1, R>;

Edit:

How would this work for an asynchronous function (assuming you wanted to cache the result and not the Promise itself)? You could certainly do this:

function withCache<F extends Function>(fn: F) {
  return (key: string) =>
      ((...args) => 
        //Wrap in a Promise so we can handle sync or async
        Promise.resolve(fn(...args)).then(res => { cache(key, res); return res; })
    ) as any as F; //Really want F or (...args) => Promise<returntypeof F>
}

But then it would be unsafe to use with a synchronous function:

//Async function
const bar = (x: number) => Promise.resolve({ x });
let barRes = withCache(bar)("bar")(1).x; //Not allowed :)

//Sync function
const foo = (x: number) => ({ x });
let fooRes = withCache(foo)("bar")(1).x; //Allowed, because TS thinks fooRes is an object :(

Is there a way to guard against this? Or to write a function that safely works for both?

Summary: @jcalz's answer is correct. In cases where synchronous functions can be assumed, or where it's okay to work with Promises directly and not the values they resolve to, asserting the function type is probably safe. However, the sync-or-async scenario described above is not possible without some as-yet unimplemented language improvements.

1 Answers

Overloads are chosen by going down the list and picking the first one that matches.

Examine the following code, which successfully compiles:

declare let f: (a: any, b?: any) => void;
declare let g: (a: any) => void;
g = f; // okay

The function f is a function which accepts one or two parameters, while g is declared as a function which accepts one. You can assign the value f to the variable g, because you can call any function of one-or-two parameters anywhere you can call a function of one parameter. It is precisely the fact that the second parameter is optional that makes this assignment work.

You can also do the other assignment:

f = g; //okay

because you can call any function of one parameter anywhere you can call a function of one-or-two parameters. That means that these function two types are mutually assignable (even though they are not equivalent, which is a bit of unsoundness).


If we look only at these two overloads:

function withCache<R, T1>(key: string, fn: (a: T1) => R): (a: T1) => R
function withCache<R, T1, T2>(key: string, fn: (a: T1, b?: T2) => R): (a: T1, b?: T2) => R

The above discussion of f and g implies that anything which matches one of those overloads will match the other. So whichever one you list first will be chosen. You can't actually use both of them, sorry.

At this point, I could suggest you start coming up with a compromise set of overloads that gives reasonable behavior, but let's back up:


Don't you just want a type-safe version of withCache()? How about this:

function withCache<F extends Function>(key: string, fn: F): F {     
    // implementation ...
}

No overloads, and the return value is always the same type as the fn parameter:

const foo = (x: number, y?: number) => x;
const fooWithCache = withCache("foo", foo); // (x: number, y?: number) => number
let fooResult1 = fooWithCache(1); // allowed :)
let fooResult2 = fooWithCache(1, 2) // allowed :)

Does that work for you? Good luck!

Related