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

43 views Asked by At

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

2

There are 2 answers

3
jcalz On BEST ANSWER

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

1
Scott Z On

I like Jcalz playground. I wouldn't have thought to do it that way. It seems very elegant. I would type the listener as a generic like so...

type EventPayload = string | number;
type CustomEventHandler<T extends EventPayload> = { payload: T }
type Listener <T extends EventPayload> =  (arg:CustomEventHandler<T>)=> void

function registerListeners(
  listeners: Record<string, Listener<never>>
) {}


registerListeners({
  event1:(arg:CustomEventHandler<"hello">)=>{},
  event3:(arg:CustomEventHandler<2>)=>{}
})

The reason your method is failing is that since you've made regiserListeners generic, the first type that is infered for T is expected to be the same type for all of the event listeners. My example allows each listener to have a constraint, but is not forced to have the same constraint as its siblings.