Reputation: 8136
I'm building a small event handling class, and I ran into a typing problem that I couldn't quite make work. Consider the following:
export interface IEventListener<A> {
(...A): boolean | void;
}
export default class EventDispatcher<A> {
private listeners: IEventListener<A>[] = [];
constructor(...listeners: IEventListener<A>[]) {
listeners.forEach((listener) => {
this.addListener(listener);
});
}
public addListener(listener: IEventListener<A>): void {
if (this.listeners.indexOf(listener) === -1) {
this.listeners.push(listener);
}
}
public removeListener(listener: IEventListener<A>): void {
const index = this.listeners.indexOf(listener);
if (index !== -1) {
this.listeners.splice(index, 1);
}
}
public triggerListeners(...A): boolean {
const args = arguments;
return this.listeners.every((listener) => {
return listener(args) === false;
});
}
}
(You'll have to excuse the made-up syntax)
Here, the generic type A
stands in for the signature of arguments to a function used both in listeners passed into the dispatcher, and in the dispatcher's triggerListeners
function.
What I'd really like to be able to do, is declare a new EventDispatcher
with a strongly typed argument interface like so:
const ed = new EventDispatcher<(name: string, street: string)>();
Or, more ideally, using inference:
const ed = new EventDispatcher((name: string, street: string) => {
console.log(name, street);
});
Such that:
ed.triggerListeners("Sandy", "100th"); // no error
ed.triggerListeners("Sandy", 100); // error
Is it possible to type the arguments of a function separately and pass them as a generic type?
Currently, I've got it set up so that listeners can only accept an object of a single type:
export interface IEventListener<T> {
(T): boolean | void;
}
const ed = new EventDispatcher<{name: string, street: string}>();
which is fine, I guess, but lacks a certain TypeScript-y finesse.
Upvotes: 0
Views: 67
Reputation: 10659
Is it possible to type the arguments of a function separately
I don't think so. At least I haven't been able to.
However, for your specific use case, I've found a way to do that. You get the benefit of type checking a separated method (triggerListener
in your case) with the parameter signature. In a simplified way:
export default class EventDispatcher<A extends Function> {
private listeners: A[] = [];
constructor(listener?: A) {
if (listener) {
this.addListener(listener);
}
}
public addListener(listener: A): void {
if (this.listeners.indexOf(listener) === -1) {
this.listeners.push(listener);
}
}
public removeListener(listener: A): void {
const index = this.listeners.indexOf(listener);
if (index !== -1) {
this.listeners.splice(index, 1);
}
}
public get triggerListeners() {
return this._triggerListeners as any as A & (() => boolean);
}
private _triggerListeners(...args: any[]): boolean {
return this.listeners.every((listener) => {
return listener.apply(null, args) === false;
});
}
}
const ed = new EventDispatcher<(name: string, street: string) => void>();
ed.addListener((name: string, street: string) => console.log(`${name}, ${street}`));
ed.triggerListeners("Sandy", 100); // Not ok: "Argument of type '100' is not assignable to parameter of type 'string'."
ed.triggerListeners("Sandy", "100"); // Ok
What the code does is provide a getter that returns _triggerListeners
with a different specific parameter signature. There's a little bit of a weird magic there with as any as A & (() => boolean)
to allow us to type cast the returned function signature to the arguments you want, plus saying the return value is boolean
as you want.
Not sure it is ideal, but at least it'd hide the ugliness inside your class, keeping its interface elegant.
The above code also allows for omitting the generic type as long as you're passing a parameter to the constructor. So this works exactly the same:
const ed = new EventDispatcher((name: string, street: string) => console.log(`${name}, ${street}`));
ed.triggerListeners("Sandy", 100); // Not ok: "Argument of type '100' is not assignable to parameter of type 'string'."
ed.triggerListeners("Sandy", "100"); // Ok
And it comes for free since TypeScript just expects you to declare the type somewhere, anywhere, either in the constructor or in the generic type.
(I haven't tested your repurposed code above, and it omits part of your original functionality, but I've used this technique before successfully and you might be able to adapt it to your needs).
Upvotes: 1