Yegor
Yegor

Reputation: 2606

Use class names to define properties in a type

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

Answers (2)

Linda Paiste
Linda Paiste

Reputation: 42218

Solution

// 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;
}

Explanation

@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) {  }
}

Typescript Playground Link

Upvotes: 1

Mirco S.
Mirco S.

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

Related Questions