Solution
// transform an event name into a handler name
type AddOn<T extends string> = `on${Capitalize<T>}`
// convert a handler name to an event name
type RemoveOn<T extends string> = T extends `on${infer E}` ? Uncapitalize<E> : T;
// generate a map of event handlers from a map of events organized by name
type EventsHandler<EventMap> = {
[K in AddOn<Extract<keyof EventMap, string>>]:
RemoveOn<K> extends keyof EventMap ? (event: EventMap[RemoveOn<K>]) => void : never;
}
Explanation
@Micro S. provided you with a great answer using a name-based solution. Another approach you can consider is utilizing a map-centric strategy.
In the DOM library, numerous functions require matching an event name such as "click" to a specific event type like MouseEvent. Notably, the event types are not distinct for every event; for instance, a "click" event encompasses the same attributes as a "mousedown" event.
If we examine how these relationships are managed in lib.dom.ts, it's evident that they define these connections through maps. There exist many maps (67 to be precise), often extending one another. Each map describes the events linked to a particular object or interface. For example, there exists an interface DocumentEventMap for a Document which extends the universal events interface GlobalEventHandlersEventMap and also extends interface DocumentAndElementEventHandlersEventMap, thereby incorporating events shared by documents and HTML elements to prevent redundancy.
Instead of creating a union:
type UserEvents = UserCreated | UserVerified;
We opt for defining a mapping:
interface UserEvents {
userCreated: UserCreated;
userVerified: UserVerified;
}
We can leverage Typescript's template literal types coupled with mapped types to craft your EventsHandler type.
We can utilize generic template literals to convert a key "userCreated" to a method name "onUserCreated," and vice versa.
type AddOn<T extends string> = `on${Capitalize<T>}`
type RemoveOn<T extends string> = T extends `on${infer E}` ? Uncapitalize<E> : T;
type A = AddOn<'userCreated'> // type: "onUserCreated"
type B = RemoveOn<'onUserCreated'> // type: "userCreated"
The keys in our EventsHandler should be in the AddOn form. By applying the AddOn type to a union of strings, we obtain a union of their mapped versions. This necessitates the keys to be strings, so we employ Extract<keyof EventMap, string> for this purpose.
This becomes our key type:
type C = AddOn<Extract<keyof UserEvents, string>> // type: "onUserCreated" | "onUserVerified"
If our mapped interface employs the AddOn version as keys, then we ought to revert to the original keys using RemoveOn. Essentially, RemoveOn<K> should represent a key in the EventMap derived from K, albeit Typescript requires validation via extends.
type EventsHandler<EventMap> = {
[K in AddOn<Extract<keyof EventMap, string>>]: RemoveOn<K> extends keyof EventMap ? (event: EventMap[RemoveOn<K>]) => void : never;
}
Applying this type to our UserEvents interface yields the desired outcome:
type D = EventsHandler<UserEvents>
results in:
type D = {
onUserCreated: (event: UserCreated) => void;
onUserVerified: (event: UserVerified) => void;
}
Consequently, we encounter the anticipated error:
Class 'UserEventsHandler' does not correctly implement interface 'EventsHandler'.
Property 'onUserVerified' is absent in type 'UserEventsHandler' but obligatory in type 'EventsHandler'.
Regrettably, you still need to specify the type for the event variable in your methods. Errors may emerge if you assign an invalid type to your event. Nonetheless, requiring only a subset of the event (e.g., onUserVerified(event: {})) or no event at all (e.g., onUserVerified()) works effectively when invoked with a UserVerified event.
Note that I retained the names from your illustration, yet the event names in the map need not align with the event class name. This scenario is acceptable too:
class UserEvent {
}
interface UserEventsMap {
created: UserEvent;
verified: UserEvent;
}
class UserEventsHandler2 implements EventsHandler<UserEventsMap> {
onCreated(event: UserEvent) { }
onVerified(event: UserEvent) { }
}
Typescript Playground Link