Search code examples
typescripttypescript-generics

How to use spread operator together with typescripts utility type `Parameters`


I built a higher order function that caches the result of a function if it is invoked with the same parameters. This is using the Parameters utility type to return a function with the same signature passing the arguments to the wrapped function.

function memoize<FN extends (...args: any) => Promise<any>>(fn: FN) {
  const cache: Record<string, Promise<Awaited<ReturnType<FN>>>> = {};

  return (...args: Parameters<FN>) => {
    const cacheKey = JSON.stringify(args);

    if (!(cacheKey in cache)) {
      // @ts-ignore
      const promise = fn(...args);
      cache[cacheKey] = promise;
    }

    return cache[cacheKey];
  };
}

Without the ts-ignore typescript will fail with Type 'Parameters<FN>' must have a '[Symbol.iterator]()' method that returns an iterator. ts(2488). How do I fix this correctly?

The function is used like this:

const cachedFunction = memoize(async (id: string) => fetch(`https://jsonplaceholder.typicode.com/todos/${id}`))
const resultFromAPI = cachedFunction("1")
const resultFromCache = cachedFunction("1")

Solution

  • The part where Parameters<FN> isn't seen as spreadable is considered a bug in TypeScript as reported in microsoft/TypeScript#36874. This can be worked around by changing the constraint from (...args: any) => ⋯ to (...args: any[]) => ⋯:

    function memoize<FN extends (...args: any[]) => Promise<any>>(fn: FN) {
      const cache: Record<string, Promise<Awaited<ReturnType<FN>>>> = {};
    
      return (...args: Parameters<FN>) => {
        const cacheKey = JSON.stringify(args);
    
        if (!(cacheKey in cache)) {
          const promise = fn(...args); // okay
          cache[cacheKey] = promise;
        }
    
        return cache[cacheKey];
      };
    }
    

    Still, this is not the recommended approach here.


    The Awaited, Parameters and ReturnType utility types are implemented as conditional types. Inside the body of memoize(), the FN type is a generic type parameter, and so Parameters<FN> and Awaited<ReturnType<FN>> are generic conditional types. TypeScript is notoriously unable to do much analysis on generic conditional types. It tends to defer evaluation of them, so inside memoize(), the type Parameters<FN> is mostly opaque. Thus when you call fn(...args) the compiler can't be sure if args is appropriate. So it either has to report an error all the time or none of the time.

    Since FN is constrained to (...args: any[]) => ⋯, calling f(...args) ends up widening FN to its constraint, which accepts any arguments at all, and therefore it ends up failing to report an error no matter what. So fn(...args) is accepted, but so is something obviously bad like fn(123):

    const promise = fn(123); // okay?!!!
    

    If you do decide to use this approach, then, you should be very careful. Generic conditional types are best avoided when possible.


    Instead of generic conditional types, the recommended approach is to make your function generic not in FN, the full function type, but in its argument list A and return type R:

    function memoize<A extends any[], R>(fn: (...args: A) => Promise<R>) {
      const cache: Record<string, Promise<R>> = {};
    
      return (...args: A) => {
        const cacheKey = JSON.stringify(args);
    
        if (!(cacheKey in cache)) {
          const promise = fn(...args); // okay
          cache[cacheKey] = promise;
        }
    
        return cache[cacheKey];
      };
    }
    

    This compiles cleanly, and the compiler is actually able to understand what you're doing well enough that if you write fn(123) you'll get the expected error

    const promise = fn(123); // error!
    //                 ~~~
    // Argument of type '[number]' is not assignable to parameter of type 'A'.    
    

    Playground link to code