Dan Prince
Dan Prince

Reputation: 29989

Creating a dynamic event handler type

I want everything that extends my component class can have type checked event handlers, using the method name to determine the type of event they handle.

class UIEvent { }
class MouseInput extends UIEvent { }
class KeyboardInput extends UIEvent { }

let Events = {
    MouseInput,
    KeyboardInput,
}

Then I'm using mapped types to create an EventTarget type that defines the mapping from name to the type of the event handler.

type EventTarget = {
    [K in keyof typeof Events]?:
      (event: InstanceType<typeof Events[K]>) => void;
}

An EventTarget can handle as many or as few of the event types as they want, so the event handler methods are optional.

Then I'm trying to use that in the component class, but I can't get the compiler to enforce the interface.

class Component implements EventTarget {
    // The compiler has no problems with this, even though it violates
    // the EventTarget interface...
    MouseInput(event: KeyboardInput) {

    }
}

I strongly suspect that I'm trying to work against structural typing here, because as soon as I add a property that makes MouseEvent distinct from KeyboardEvent, the type checks work.

class UIEvent<T> { t: T }
class MouseInput extends UIEvent<"MouseInput"> {}
class KeyboardInput extends UIEvent<"KeyboardInput"> {}

This works, but I would much rather use the event's constructor as the discriminator, rather than adding a distinct property type.

And finally, I want to extend this contract to subtypes of the component class.

class Clickable extends Component {
    // This should also fail to typecheck, but interfaces aren't inherited
    MouseInput(event: KeyboardInput) {

    }
}

The closest I can get right now is to have every subtype implement EventTarget explicitly.

Upvotes: 0

Views: 419

Answers (1)

Titian Cernicova-Dragomir
Titian Cernicova-Dragomir

Reputation: 249556

You are right, you are working against structural typing here. As long as the classes have no members to distinguish them the compiler will consider them to be compatible.

I'd argue that your design will probably include some properties that will make these incompatible beyond this simple proof of concept. So this will already give an error as expected:

export class UIEvent { }
class MouseInput extends UIEvent { public x!: number; public y!: number }
class KeyboardInput extends UIEvent { public key!: string }

let Events = {
    MouseInput,
    KeyboardInput,
}

type EventTarget = {
    [K in keyof typeof Events]?:
      (event: InstanceType<typeof Events[K]>) => void;
}

class Component implements EventTarget {
    MouseInput(event: KeyboardInput) { // Error now 

    }
}

If you don't want to expose any member, you can just declare a private member of type undefined. This will make the types incompatible but not have anu runtime impact:

class MouseInput extends UIEvent { private isMouse: undefined }
class KeyboardInput extends UIEvent { private isKeyboard: undefined }

Upvotes: 1

Related Questions