Home > Blockchain >  How to make a generic function returning a promise with event arguments?
How to make a generic function returning a promise with event arguments?

Time:11-09

I have a non-generic function that waits for an "EventEmitter" (not really since I need to use it on other classes that have once but don't extend EventEmitter):

export function waitEvent(emitter: { once: Function }, event: string): Promise<any[]> {
    return new Promise((resolve) => {
        emitter.once(event, (...args: any[]) => {
            resolve(args);
        });
    });
}

How do I make it generic?

For example, imagine Typescript knows that variable page can emit an event loaded with args (title: string). How do I make it so for code const [title] = await waitEvent(page, 'loaded') it knows that title will be a string?

Here's a real world example:

import {chromium} from "playwright";
const browser = await chromium.launch();
const page = await browser.newPage();
page.once('popup', async (page) => {
    await page.click('h1');
});
const [popup] = await waitEvent(page, 'popup');
await popup.click('h1');

The usage in the regular .once call knows page is a Page, the one after the waitEvent thinks any with my original version, and Worker with @jcalz's approach, so the second call to .click doesn't work.

CodePudding user response:

The naive first-order call signature for waitEvent() looks like this:

declare function waitEvent<K extends string, A extends any[]>(emitter: {
    once(event: K, cb: (...args: A) => void): void
}, event: K): Promise<A>;

As long as you call waitEvent() on an emitter with a once() method of the right shape, the compiler should be able to infer A for you:

const thing = {
    once(event: "loaded", cb: (title: string) => void) { }
}
const [title] = await waitEvent(thing, "loaded");
title // string

const otherThing = {
    once(event: "exploded", cb: (blastRadius: number) => void) { }
}
const [rad] = await waitEvent(otherThing, "exploded");
rad // number

But unfortunately this will beak down in most real-world situations I can think of. TypeScript doesn't have much support for expressing and manipulate higher order types. In your case, the parameters of the once(event, callback) method of your event emitter probably can't be written with simple independent types like the thing and otherThing examples shown above.

Instead, they are correlated to each other. So if event is, say, "loaded", then callback is of type (title: string) => void. But if event is something else (I will say, uh, "exploded"), then callback will be of some other type (say, (blastRadius: number) => void). Meaning that the thing and otherThing examples would need to be combined into a single object that could deal with multiple ways of calling once().

There are different ways to express such a relationship in TypeScript, and most of those ways are not conducive to type manipulation. Traditionally, you would do this with an overload with multiple call signatures:

interface MyEventEmitter {
    once(event: "loaded", cb: (title: string) => void): void;
    // ... more ... //
    once(event: "exploded", cb: (blastRadius: number) => void): void;
}

And this seems to be what the "playwright" library is doing. That's great when you actually directly call once() on a value of type MyEmitter:

declare const emitter: MyEventEmitter;
emitter.once("loaded", title => title.toUpperCase()); // okay
emitter.once("exploded", rad => rad.toFixed(2)); // okay

But TypeScript cannot answer the question "if I call once() with an event argument of type "loaded", what is the type of cb" unless you actually call it directly. TypeScript cannot easily tell you the full relationship between once()'s event and the arg type of cb.

This is a design limitation, discussed at microsoft/TypeScript#32164 (among other places). Generally speaking, if you probe for the type of once(), such as using the Parameters<T> utility type on an overloaded function type, you will only get information about the last call signature:

type OnceParams = Parameters<MyEventEmitter['once']>;
// type OnceParams = [event: "exploded", cb: (blastRadius: number) => void]
// (where is the information abut the other call signatures)?

And so the above waitEvent() fails:

const [title] = await waitEvent(emitter, "loaded");
title // number ?!!?!

There are various questions about this and workarounds (see Parameters generic of overloaded function doesn't contain all options for example) but none of them are pretty and they can only deal with some finite maximum number of call signatures. If you're going to do that, you might want to just give up on having emitter be of an arbitrary type and instead hardcode waitEvent() for the actually expected type of emitter.


Another way of writing the above would be if once() were generic in some mapping interface like this:

 interface EventArgs {
    loaded: [title: string],
    exploded: [blastRadius: number]
}
interface MyEventEmitter {
    once<K extends keyof EventArgs>(event: K, cb: (...args: EventArgs[K]) => void): void;
}

Again, great when you actually directly call once() on a value of type MyEmitter:

declare const emitter: MyEventEmitter;
emitter.once("loaded", title => title.toUpperCase()); // okay
emitter.once("exploded", rad => rad.toFixed(2)); // okay

But TypeScript still cannot answer the question "if I call once() with an event argument of type "loaded", what is the type of cb" unless you actually call it directly.

It's a slightly different limitation from the overloaded version; in this case maybe microsoft/TypeScript#40179 is a better source. But it's the same basic problem; the function cannot easily be probed.

And so waitEvent() still does the wrong thing, albeit a slightly different wrong thing:

const [title] = await waitEvent(emitter, "loaded");
title // string | number (better, I guess?, but not great)

Here the compiler gives up and just infers K and A as their independent constraints for EventEmitter.


The only other way I could imagine representing this is with a rest parameter of a union of tuple types:

interface EventArgs {
    loaded: [title: string],
    exploded: [blastRadius: number]
}
type OnceArgs = { [K in keyof EventArgs]: 
  [event: K, cb: (...args: EventArgs[K]) => void] 
}[keyof EventArgs];
/* type OnceArgs = 
    [event: "loaded", cb: (title: string) => void] | 
    [event: "exploded", cb: (blastRadius: number) => void] 
*/
interface MyEventEmitter {
    once(...args: OnceArgs): void;
}

This sort of behaves like overloads, and are even shown in IntelliSense as overloads, but due to some limitations (see microsoft/TypeScript#42987) it isn't as nice to use:

declare const emitter: MyEventEmitter;
emitter.once("loaded", title => title.toUpperCase()); // error!
emitter.once("loaded", (title: string) => title.toUpperCase()) // okay
emitter.once("exploded", rad => rad.toFixed(2)); // error!
emitter.once("exploded", (rad: number) => rad.toFixed(2)); // okay

const [title] = await waitEventNaive(emitter, "loaded"); // error!!!        

So that's probably a non-starter. Still, this is at least possible to inspect it and rewrite waitEvent accordingly:

declare function waitEvent<
  A extends [string, (...args: any) => void], 
  K extends A[0]
>(emitter: {
    once(...args: A): void
}, event: K): Promise<Parameters<Extract<A, [K, any]>[1]>>;

const [title] = await waitEvent(emitter, "loaded");
title // string
const [rad] = await waitEvent(emitter, "exploded");
rad // number

Hooray, I guess. But the chance that your third-party library will use this technique is low (maybe if it were easier to use?).


And that means probably what you should do is just figure out what type emitter is going to be, and write a targeted version of waitEvent() with a hardcoded relationship between the input and output types. In the above example that would look like:

declare function waitEventHardCoded(
  emitter: MyEventEmitter, event: "loaded"): Promise<[title: string]>;
declare function waitEventHardCoded(
  emitter: MyEventEmitter, event: "exploded"): Promise<[blastRadius: number]>;

And then this would just work:

const [title] = await waitEventHardCoded(emitter, "loaded");
title.toUpperCase();
const [rad] = await waitEventHardCoded(emitter, "exploded");
rad.toFixed(2);

It's not satisfying, but it's what I'd do until and unless support for higher order function type manipulation in TypeScript improves significantly.

Playground link to code

  • Related