Search code examples
typescript

Type not inferred correctly in generic function with key and value as args


I have a type MyMap with 2 different keys that take an array of callbacks with different signatures.

type MyMap = {
   type1: (() => void)[] 
   type2: ((data: string) => void)[]
}

I want to create a generic function that takes a key and a callback, and add it to an object of type MyMap.

const map: MyMap = { type1: [], type2: [] };

function addToMap<T extends keyof MyMap>(k: T,  cb: MyMap[T][number]): void {
        map[k].push(cb);
}

TypeScript shows me the error when i try to push the callback in the array:

Argument of type '(() => void) | ((data: string) => void)' is not assignable to parameter of type '() => void'.
  Type '(data: string) => void' is not assignable to type '() => void'.
    Target signature provides too few arguments. Expected 1 or more, but got 0.(2345)

Is there a way to make TypeScript understand that the callback I provided is the right one for the key?


Solution

  • TypeScript can't "see" the fact that every member of MyMap with key K is an array that allows you to push a value of type MyMap[K][number] onto it. It can verify the for an individual K, but when K is generic, it doesn't know how. It lacks the ability to automatically convert a list of facts into a single generality. So it doesn't see map[k].push as a single coherent method; instead it sees it as a union of methods, and any correlation between map[k] and cb is lost.


    If you want the compiler to follow your logic, you'll need to explicitly represent the operations in terms of such a generality. The recommended approach is described in microsoft/TypeScript#47109. Starting from your MyMap type,

    type MyMap = {
      type1: (() => void)[]
      type2: ((data: string) => void)[]
    }
    const map: MyMap = { type1: [], type2: [] };
    

    we can create the "base" version of the type, corresponding to just the elements of the arrays:

    type MyMapBase = { [K in keyof MyMap]: MyMap[K][number] }
        
    /* type MyMapBase = {
        type1: () => void;
        type2: (data: string) => void;
    } */
    

    And then we can write addToMap() as a generic function operating on MyMapBase explicitly:

    function addToMap<K extends keyof MyMap>(k: K, cb: MyMapBase[K]): void {
      const m: { [K in keyof MyMap]: MyMapBase[K][] } = map;
      m[k].push(cb);
    }
    

    Here, we've just assigned map to a variable m of the mapped type {[K in keyof MyMap]: MyMapBase[K][]}. The compiler is happy with the assignment because it has to verify each property individually, and it works. But now m is written explicitly as a type that abstracts over MyMapBase generically. So m[k] is of the single generic type MyMapBase[K][]. And as such, you can easily push() a value of type MyMapBase[K] onto it.


    Note that this would be a lot more straightforward looking if we just started with MyMapBase in the first place, since that's the actual underlying type that your abstracting over. But if you already have MyMap defined somewhere, the code here shows you can still compute the required things from it.

    Playground link to code