Search code examples
typescripttypescript-generics

TypeScript incorrectly inferring function parameter when mapping over config object


Sorry for the poor title, but I'm not sure how to describe the issue I'm seeing. My use case is fairly complicated, but I'll do my best to distill it down to the bare essentials.

I have a list of config objects which have an eventName whose value corresponds to the name of an event which can be triggered in my application. When said events are triggered, any listeners are called with an event object of the appropriate type.

E.g.:

type EventHandler<E> = (event: E) => void;
// Just a map of eventname -> handler (with appropriate event type as an arg)
type EventMap = {
  onStart: EventHandler<StartEvent>;
  onEnd: EventHandler<EndEvent>;
};
const configs = [
  buildConfig('onStart'),
  buildConfig('onEnd')
];

I then want to map over this config object and construct another list of (different) config object who have a handlerFactory property which is a function that builds the appropriate event handler for the event. The reason this is a function is to provide a closure which can supply additional context to the event handler.

type AdditionalContext = unknown; // Actual type is not important for this example
type HandlerConfig<E extends keyof EventMap = keyof EventMap> = {
  eventName: E;
  handlerFactory: (context: AdditionalContext) => EventMap[E];
};

The issue is that in my .map callback TS is unable to infer the proper event type:

const handlerConfigs: HandlerConfig[] = configs.map(({ eventName }) => {
  return {
    eventName,
    handlerFactory: (context) => {
      // FIXME: Why is e `any`? It should be the event type that corresponds to the eventName event handler.
      return (e) => {
        console.log('Did something cool!', context);
      };
    }
  };
});

Here's a TS playground showing the error.

Is what I'm trying to do even possible?

Any help would be greatly appreciated!

UPDATE: added the type definition for EventHandler to the code above.


Solution

  • The main problem with

    const handlerConfigs: HandlerConfig[] = configs.map(({ eventName }) => {
        return {
            eventName,
            handlerFactory: (context) => {
                return (e) => { // error, implicit any
                    console.log('Did something cool!', context);
                };
            }
        };
    });
    

    is that inside the callback function, the type of eventName is a union. If you could narrow eventName to each member of the union and check it separately, you'd have a chance to get desired behavior:

    configs.map(({ eventName }) => {
        if (eventName === "onEnd") {
            return {
                eventName,
                handlerFactory: (context) => {
                    return (e) => {
                        console.log('Did something cool!', context);
                    };
                }
            } satisfies HandlerConfig<"onEnd">;
        } else return {
            eventName,
            handlerFactory: (context) => {
                return (e) => {
                    console.log('Did something cool!', context);
                };
            }
        } satisfies HandlerConfig<"onStart">;
    });
    

    But that's redundant. You'd think the compiler could just check a single code block against each possible narrowing, keeping track of the correlation between union members across different lines of code, but that's not how it works. (See https://twitter.com/SeaRyanC/status/1544414378383925250 )

    This is the subject of microsoft/TypeScript#30581.

    The easiest thing you can do is just give up on the compiler checking accurately and widen things or assert things until it stops complaining. For example:

    configs.map(({ eventName }) => {
        const ret: HandlerConfig = {
            eventName,
            handlerFactory: (context) => {
                return (e: StartEvent | EndEvent) => {
                    console.log('Did something cool!', context);
                };
            }
        };
        return ret;
    });
    

    That works because (x: StartEvent | EndEvent) => void satisfies both event handlers.


    On the other hand, if you need the compiler to follow logic like this, you can take the approach recommended in microsoft/TypeScript#47109, where you use generics instead of unions.

    The idea is to write operations in terms of some "basic" key-value mapping type, and generic indexes into either that basic type, or into mapped types over that type.

    The goal is that you want things that would normally look like unions to look like a single generic type. Here's how I'd do it for this example code. The basic type looks like:

    interface EventMap {
        onStart: StartEvent,
        onEnd: EndEvent
    }
    

    (note that I called this EventMap, and renamed your EventMap to HandlerMap, since it's more descriptive):

    type HandlerMap<K extends keyof EventMap = keyof EventMap> =
        { [P in K]: (event: EventMap[P]) => void }
    

    The type HandlerMap is the same as before (as your EventMap), but now it's generic, so HandlerMap<K> is just the piece of the object with the key K.

    And we have to change HandlerConfig too:

    type HandlerConfig<K extends keyof EventMap = keyof EventMap> = {
        eventName: K;
        handlerFactory: (context: AdditionalContext) => HandlerMap<K>[K];
    };
    

    That HandlerMap<K>[K] is similar to (event: EventMap[K]) => void except that it distributes over unions in K. That is, it's a distributive object type as coined in microsoft/TypeScript#47109. This assumes that if K is a union, then handlerFactory should also be a union. If you don't care about that, you can just write

    type HandlerConfig<K extends keyof EventMap = keyof EventMap> = {
        eventName: K;
        handlerFactory: (context: AdditionalContext) => (event: EventMap[K]) => void;
    };
    

    but that ends up just blurring the distinction between the two union members, and you couldn't write a version that actually changed based on which event type you got.

    Anyway, from here we can finally write handlerConfigs by making the map() callback generic, saying that it coverts an EventConfig<K> into a HandlerConfig<K>:

    const handlerConfigs: HandlerConfig[] = configs.map(
        <K extends keyof EventMap>({ eventName }: EventConfig<K>): HandlerConfig<K> => {
            return {
                eventName,
                handlerFactory: (context) => {
                    return (e) => {
                        console.log('Did something cool!', context);
                    };
                }
            };
        });
    

    Now when you inspect e, you'll see it's EventMap[K], the type you expect it to be (and you could, if you want, delegate it to something that depends on eventName).


    Is it worth that refactoring? Not obviously from this use case. It's so easy to just annotate e with something wide and move on. But in some situations, this refactoring is the only way to come close to type safety.

    Playground link to code