typescriptgeneric-type-parameters

TypeScript: Implement type constrain on parameter of functions inside a record passed as argument to another function


I have a function that accepts a Record with event listener callbacks as values. I'd like to enforce that the first argument of an event callback within this record is typed as a CustomEvent<PayloadThatConformsToABaseType>. I've tried the following:

type EventPayload = string | number;
interface CustomEvent<T> { payload: T }

function registerListeners<T extends EventPayload>(
  listeners: Record<string, (e: CustomEvent<T>) => void>
) {}
// Gets inferred as `registerListeners<"hello">(listeners: Record<string, (e: CustomEvent<"hello">) => void>): void`
// Type '"hello"' is not assignable to type '2'.
registerListeners({
    eventOne: (event: CustomEvent<'hello'>) => {},
    eventTwo: (event: CustomEvent<2>) => {}
})


function registerListeners(
  listeners: Record<string, <T extends EventPayload>(e: CustomEvent<T>) => void>
) {}
// Gets inferred as `registerListeners(listeners: Record<string, <T extends EventPayload>(e: CustomEvent<T>) => void>): void`
// Type 'EventPayload' is not assignable to type '"hello"'
registerListeners({
    eventOne: (event: CustomEvent<'hello'>) => {},
    eventTwo: (event: CustomEvent<2>) => {}
})

function registerListeners(listeners: Record<string, (e: CustomEvent<any>) => void>) {}
// Works but I can no longer constrain the event params
registerListeners({
    eventOne: (event: CustomEvent<'hello'>) => {},
    eventTwo: (event: CustomEvent<{a: string}>) => {}
})

How do I enforce that the callbacks passed to registerListeners accept an acceptable event as argument?

TS Playground link


Solution

  • It looks like you want to use a mapped type where the generic type parameter T corresponds to the type argument to CustomEvent for each key (e.g., for your example, the type argument for T would be { eventOne: "hello"; eventTwo: 2; }. Like this:

    function registerListeners<T extends Record<keyof T, EventPayload>>(
        listeners: { [K in keyof T]: (e: CustomEvent<T[K]>) => void }
    ) { }
    

    Because this is a homomorphic mapped type (see What does "homomorphic mapped type" mean?) then the compiler knows how to infer from it when you call registerListeners():

    registerListeners({
        eventOne: (event: CustomEvent<'hello'>) => { },
        eventTwo: (event: CustomEvent<2>) => { }
    })
    

    If you inspect with IntelliSense, you'll see that T is inferred as { eventOne: "hello"; eventTwo: 2; } as expected. Depending on the use case you could then go on to compute other types that depend on T (in general the function might return something that needs to keep track of which events go with which keys).

    Playground link to code