You can define an extra interface that will contain all the function definitions. And use mapped types to transform these functions into the signature for on
and the signature for emit
. The process is a bit different for the two so I will explain.
Lets consider the following event signature interface:
interface Events {
scroll: (pos: Position, offset: Position) => void,
mouseMove: (pos: Position) => void,
mouseOther: (pos: string) => void,
done: () => void
}
For on
we want to create new functions that take as first argument the name of the property in the interface and the second argument the function itself. To do this we can use a mapped type
type OnSignatures<T> = { [P in keyof T] : (event: P, listener: T[P])=> void }
For emit, we need to add a parameter to each function that is the event name, and we can use the approach in this answer
type IsValidArg<T> = T extends object ? keyof T extends never ? false : true : true;
type AddParameters<T, P> =
T extends (a: infer A, b: infer B, c: infer C) => infer R ? (
IsValidArg<C> extends true ? (event: P, a: A, b: B, c: C) => R :
IsValidArg<B> extends true ? (event: P, a: A, b: B) => R :
IsValidArg<A> extends true ? (event: P, a: A) => R :
(event: P) => R
) : never;
type EmitSignatures<T> = { [P in keyof T] : AddParameters<T[P], P>};
Now that we have the original interface transformed we need to smush all the functions into a single one. To get all the signatures we could use T[keyof T]
(ie EmitSignatures<Events>[keyof Events]
) but this would return a union of all the signatures and this would not be callable. This is where an interesting type comes in from this answer in the form of UnionToIntersection
which will transform our union of signatures into an intersection of all signatures.
Putting it all together we get:
interface Events {
scroll: (pos: Position, offset: Position) => void,
mouseMove: (pos: Position) => void,
mouseOther: (pos: string) => void,
done: () => void
}
type UnionToIntersection<U> =
(U extends any ? (k: U) => void : never) extends ((k: infer I) => void) ? I : never;
type OnSignatures<T> = { [P in keyof T]: (event: P, listener: T[P]) => void }
type OnAll<T> = UnionToIntersection<OnSignatures<T>[keyof T]>
type IsValidArg<T> = T extends object ? keyof T extends never ? false : true : true;
// Works for up to 3 parameters, but you could add more as needed
type AddParameters<T, P> =
T extends (a: infer A, b: infer B, c: infer C) => infer R ? (
IsValidArg<C> extends true ? (event: P, a: A, b: B, c: C) => R :
IsValidArg<B> extends true ? (event: P, a: A, b: B) => R :
IsValidArg<A> extends true ? (event: P, a: A) => R :
(event: P) => R
) : never;
type EmitSignatures<T> = { [P in keyof T]: AddParameters<T[P], P> };
type EmitAll<T> = UnionToIntersection<EmitSignatures<T>[keyof T]>
interface TypedEventEmitter<T> {
on: OnAll<T>
emit: EmitAll<T>
}
declare const myEventEmitter: TypedEventEmitter<Events>;
myEventEmitter.on('mouseMove', pos => { }); // pos is position
myEventEmitter.on('mouseOther', pos => { }); // pos is string
myEventEmitter.on('done', function () { });
myEventEmitter.emit('mouseMove', new Position());
myEventEmitter.emit('done');
Special thanks to @jcalz for a piece of the puzzle
Edit
If we already have a base class that has very general implementations of on
and emit
we need to do a bit of elbow twisting with the type system.
// Base class
class EventEmitter {
on(event: string | symbol, listener: (...args: any[]) => void): this { return this;}
emit(event: string | symbol, ...args: any[]): this { return this;}
}
interface ITypedEventEmitter<T> {
on: OnAll<T>
emit: EmitAll<T>
}
// Optional derived class if we need it (if we have nothing to add we can just us EventEmitter directly
class TypedEventEmitterImpl extends EventEmitter {
}
// Define the actual constructor, we need to use a type assertion to make the `EventEmitter` fit in here
const TypedEventEmitter : { new <T>() : TypedEventEmitter<T> } = TypedEventEmitterImpl as any;
// Define the type for our emitter
type TypedEventEmitter<T> = ITypedEventEmitter<T> & EventEmitter // Order matters here, we want our overloads to be considered first
// We can now build the class and use it as before
const myEventEmitter: TypedEventEmitter<Events> = new TypedEventEmitter<Events>();
Edit for 3.0
Since the time of writing, typescript has improved it's ability to map functions. With Tuples in rest parameters and spread expressions we can replace the multiple overloads of AddParameters
with a cleaner version (and IsValidArg is not required):
type AddParameters<T, P> =
T extends (...a: infer A) => infer R ? (event: P, ...a: A) => R : never;