Reputation: 2606
Let's say I have some event classes:
class UserCreated { ... }
class UserVerified { ... }
And then there's a class to handle those events:
class UserEventsHandler extends Something {
onUserCreated(event) { ... }
onUserVerified(event) { ... }
}
Is there a way to model such types in TypeScript? Specifically, can I somehow define required properties and methods based on class names? Here's the desired outcome:
type UserEvents = UserCreated | UserVerified; // ?
type EventsHandler<TEvents> = ?
// Compile-time error: 'onUserVerified' is required
class UserEventsHandler extends Something implements EventsHandler<UserEvents> {
onUserCreated(event) { ... }
/* onUserVerified(event) { ... } */
}
Compile-time template literals are obviously required for this, but I don't see the other piece of the puzzle in TypeScript, i.e. any ability to use class names to define property constraints. I'd be happy to be proven wrong though.
Upvotes: 1
Views: 1240
Reputation: 42218
// convert an event name to 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;
// create a map of event handler from a map of events keyed by name
type EventsHandler<EventMap> = {
[K in AddOn<Extract<keyof EventMap, string>>]:
RemoveOn<K> extends keyof EventMap ? (event: EventMap[RemoveOn<K>]) => void : never;
}
@Micro S. has given you an excellent answer with a name-based solution. An alternative approach that you might consider is using a map-based approach.
There are many functions in DOM library that have to match an event name such as "click"
to a specific event type such as MouseEvent
. The event types are not unique for every event. A "click" event has all of the same information as a "mousedown" event.
If you look at how this is handled in lib.dom.ts
you will see that they define these relationships using maps. Lots and lots of maps (67 of them), many of which extend each other. Each map describes the events associated with a particular object or interface. For example there is an interface DocumentEventMap
for a Document
which extends the universal events interface GlobalEventHandlersEventMap
and also extends interface DocumentAndElementEventHandlersEventMap
which includes the events that are shared by documents and HTML elements so that they don't need to be written out twice.
So instead of defining a union:
type UserEvents = UserCreated | UserVerified;
We want to define a mapping:
interface UserEvents {
userCreated: UserCreated;
userVerified: UserVerified;
}
Now we can work some Typescript magic with the new template literal types along with mapped types to create your EventsHandler
type.
We can create generic template literals to convert a key "userCreated"
to a method name "onUserCreated"
and back.
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"
We want our keys of our EventsHandler
to be the AddOn
version. We can apply the type AddOn
to a union of strings and get a union of their mapped versions. This requires that the keys must be a string
so we use Extract<keyof EventMap, string>
for that.
This is our key type:
type C = AddOn<Extract<keyof UserEvents, string>> // type: "onUserCreated" | "onUserVerified"
If our mapped interface uses the AddOn
version as the keys, then we need to use RemoveOn
to go back to the original keys. We know that this is the inverse and therefore RemoveOn<K>
should be a key of the EventMap
that we derived K
from, but Typescript will not make that assumption so we have to double check with 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 interface UserEvents
gives us the desired result:
type D = EventsHandler<UserEvents>
resolves to:
type D = {
onUserCreated: (event: UserCreated) => void;
onUserVerified: (event: UserVerified) => void;
}
Now we get the error that we are after:
Class 'UserEventsHandler' incorrectly implements interface 'EventsHandler'.
Property 'onUserVerified' is missing in type 'UserEventsHandler' but required in type 'EventsHandler'.
Annoyingly, you still have to define the type for the event
variable in your methods. You will get errors if you assign an invalid type for your event
. An invalid type would be one that requires properties which are not present in our expected event type, but it's fine to require only a subset of the event onUserVerified(event: {})
or no event at all onUserVerified()
as these will both work when called with a UserVerified
event.
Note that I have kept all the names the same as in your example, but the names of the events in the map do not have to match the name of the event class
. This is fine too:
class UserEvent {
}
interface UserEventsMap {
created: UserEvent;
verified: UserEvent;
}
class UserEventsHandler2 implements EventsHandler<UserEventsMap> {
onCreated(event: UserEvent) { }
onVerified(event: UserEvent) { }
}
Upvotes: 1
Reputation: 2610
It is possible, but I can't recommend using the class name for that. To make this work, you would need the type information of UserCreated.constructor.name
. But the type of that will always be string
and not "UserCreated"
. Thus you need some hacky types to wrap your class with to change that, and even then, I'm not 100% sure it would be feasible, depending on your runtime code. I'll give you an example of how you could implement your wished behavior without class.name, but with a type string. You can tinker around with it to make it more suitable for your case. The key to make this work are template literal types:
interface EventInterface {
readonly type: string;
}
class UserCreated implements EventInterface {
// because type is readonly, UserCreated.type resolves to "UserCreated" and not string
type = "UserCreated";
}
type EventHandler<T extends EventInterface> = {
// to concat strings in types you need to use template literal types
[K in `${"on"}${T["type"]}`]: (event: T) => void;
}
// works!
class UserEventHandler implements EventHandler<UserCreated> {
public onUserCreated(event: UserCreated) {
}
}
// won't work
class UserEventHandlerError implements EventHandler<UserCreated> {
public wrongFunction(event: UserCreated) {
}
}
Update: Now also contains example for multiple events at once playground
Another note about UserCreated.constructor.name
. If you plan to use this in code and not just on types, I would advise against it. If you use UserCreated.constructor.name
on runtime to find out how the event-listener function is named, you may be badly surprised in production. The name information of UserCreated.constructor.name
is considered debug information, and minifier don't respect it. After minification, your class will look something like this class a_ {...}
, and now UserCreated.constructor.name
will return the name a_
. Thats why it's better to rely on actual strings. You could deactivate minification of function names, but this will increase bundle size quite a bit.
Upvotes: 2