Home > other >  How to define a TypeScript type with a variable number of generics
How to define a TypeScript type with a variable number of generics

Time:07-22

I'm attempting to make an emit function that can accept multiple arguments. Furthermore, the 2nd argument and beyond will be validated by TypeScript based on the 1st argument (the event).

I have the code below as a starting point, but it obviously doesn't work as intended.

type CallbackFunc<T extends any[]> = (...args: T) => void;
interface TrackablePlayerEventMap {
    'play:other': CallbackFunc<['audio' | 'video']>;
    error: CallbackFunc<[Error, Record<string, unknown>]>;
}

const eventHandlers: Partial<TrackablePlayerEventMap> = {};

function on<K extends keyof TrackablePlayerEventMap>(event: K, callback: TrackablePlayerEventMap[K]) {
}

function emit<K extends keyof TrackablePlayerEventMap>(event: K, ...args: TrackablePlayerEventMap[K]) {
    const handler = eventHandlers[event];

    handler(...args);
}

on('error', (e, metadata) => {
    console.log(e, metadata)
});
on('play:other', x => {
    console.log(x);
});

// This should be valid
emit('error', new Error('Ouch'), { count: 5 });

// This should be invalid
emit('error', 123, 456);

CodePudding user response:

In order to write handler(...args) once inside the body of emit() and have it compile with no errors, you need to make use of the technique introduced in microsoft/TypeScript#47109 as a fix for an issue I've called "correlated unions", as mentioned in microsoft/TypeScript#30581.

The first step is to come up with a type that maps your event values to the parameters list (the args array). It's like your TrackablePlayerEventMap type, but the properties are just parameter tuple types and not function types:

interface TPEventParamsMap {
    'play:other': ['audio' | 'video'],
    error: [Error, Record<string, unknown>];
}

Then we give eventHandlers type a type written explicitly in terms of TPEventParamsMap:

const eventHandlers: {
    [K in keyof TPEventParamsMap]?: CallbackFunc<TPEventParamsMap[K]>
} = {};

Conceptually that's the exact same as your Partial<TrackablePlayerEventMap> type, but this one is explicitly written as a mapped type on TPEventParamsMap, which the compiler can use to keep track of the correlation between the event type (of type K) and the parameters to the event handler function (of type TPEventParamsMap[K]).

Continuing:

function emit<K extends keyof TPEventParamsMap>(
    event: K,
    ...args: TPEventParamsMap[K]
) {
    const handler = eventHandlers[event];
    handler?.(...args); // okay
}

That compiles with no error. Note that since eventHandlers is Partial it might not have a value at key event, so we are using the optional chaining operator (?.) to call the function only if it exists.

And emit() behaves as desired from the caller's side too:

emit('error', new Error('Ouch'), { count: 5 }); // okay
emit('error', 123, 456); // error, 123 is not an Error

Playground link to code

  • Related