6

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.

4
  • which should be the correct over load for this let fooResult2 = fooWithCache(1, 2) as per your code? Commented Nov 9, 2017 at 18:18
  • I would expect the signature of fooWithCache to be (a: number, b?: number) => number, just like foo. Commented Nov 9, 2017 at 18:26
  • Edited the question to clarify my expectations. Commented Nov 9, 2017 at 18:29
  • what is the value of console.log(fooWithCache) when you use const foo = (x: number, y?: number) => x + (y || 0); have you tried using const foo = (x: number, y: number = 0) => x + y ; setting a default value for 2nd parameter. Commented Nov 9, 2017 at 18:52

1 Answer 1

6

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!

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

6 Comments

Thanks for the detailed explanation! What if instead I wanted to, say, be able to set they key for each call (something like const fooWithCache = withCache(foo); let barResult = fooWithCache("bar", 1, 2))?
If you mean take a function type and prepend an argument to it, there's no programmatic way to represent that in TypeScript without something like variadic kinds which is not currently part of the language. You could try to do a bunch of overloads as you did above, but you'll find you need to make compromises about what types of function you can accept. Or you could refactor to something TypeScript would understand; maybe something like withCache(foo).forKey("bar")(1,2).
Just tried out your original answer and am having some trouble. Using the implementation I gave in the question, but with your new signature, Typescript is giving me the following error on the inner function: Type '(...args: any[]) => any' is not assignable to type 'F'. Am I missing something?
TypeScript isn't able to infer that the thing you're returning is really of type F in the implementation. You could use an assertion like return ((...args: any[]) => {...}) as any as F; and it would not complain. This is fairly safe: except for possible weirdness with this or functions with extra properties, the function type you output will be the same as the type input.
Final question: how would this work if F were an async function and I wanted to cache the resolved value? The implementation would be easy enough, and I could still assert the return type, but then it would be unsafe to call with a synchronous function. Is there a way to guard against that, or even write something that would work for both? I've edited the question with an example.
|

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Start asking to get answers

Find the answer to your question by asking.

Ask question

Explore related questions

See similar questions with these tags.