1

I'm trying to make a typesafe EventEmitter, however I cannot enforce that the interface passed to the generic is of type EventMap without TypeScript complaining.

type EventHandler = () => void
type EventMap = Record<string, EventHandler>

interface EventListener {
  handler: EventHandler
  once: boolean
}

export class Emitter<Events extends EventMap> {
  private listeners = new Map<keyof Events, EventListener[]>()

  private addListener<E extends keyof EventMap>(type: E, listener: EventListener) {
    const listeners = this.listeners.get(type) || []
    this.listeners.set(type, [...listeners, listener])
  }

  @bind
  public on<E extends keyof EventMap>(type: E, handler: Events[E]) {
    this.addListener(type, { handler, once: false })
  }
}


interface TestEvents {
  test: (a: number) => void,
}

class Test extends Emitter<TestEvents> {}

Gives me

Type 'TestEvents' does not satisfy the constraint 'Record<string, EventHandler>'.
Index signature is missing in type 'TestEvents'.
Sebastian Olsen
  • 10,318
  • 9
  • 46
  • 91
  • please provide definition of `Record<,>` type is there an indexer required? something like: `[index:string] : string` – Rafal Nov 26 '18 at 13:59
  • 1
    `Record` is a built in type in TypeScript. Edit: https://stackoverflow.com/questions/51936369/what-is-the-record-type-in-typescript – Sebastian Olsen Nov 26 '18 at 14:00

2 Answers2

5

You need to constrain it a bit differently, you want all keys of Events to be EventHandlers not necessarily for Events to have an index signature. You could use the following:

type EventHandler = (...a: any[]) => void

interface EventListener {
    handler: EventHandler
    once: boolean
}

export class Emitter<Events extends Record<keyof Events, EventHandler>> {
    private listeners = new Map<keyof Events, EventListener[]>()

    private addListener<E extends keyof Events>(type: E, listener: EventListener) {
        const listeners = this.listeners.get(type) || []
        this.listeners.set(type, [...listeners, listener])
    }

    public on<E extends keyof Events>(type: E, handler: Events[E]) {
        this.addListener(type, { handler, once: false })
    }
}


interface TestEvents {
    test: (a: number) => void,
}

class Test extends Emitter<TestEvents> { }
new Test().on("test", a => a.toExponential) // a is number.

There is a problem with this approach however, typescript will not be able to infer the argument type if you have multiple events in the map (which you probably will)

interface TestEvents {
    test: (a: number) => void,
    test2: (a: number) => void,
}

class Test extends Emitter<TestEvents> { }
new Test().on("test", a => a.toExponential) // a is any

This is fixable, but the types get more complicated, see here for a very similar idea.

Titian Cernicova-Dragomir
  • 230,986
  • 31
  • 415
  • 357
  • So, I ended up making my emitter a bit different from the traditional node.js one, by using `extends object` and making my map's types represent a single value instead of having multiple arguments. Thank you for your answer though. – Sebastian Olsen Nov 26 '18 at 14:46
2

Since Record is:

type Record<K extends string, T> = {
    [P in K]: T;
}

you are lacking index as defined:

interface TestEvents {
    test: () => void;
    [index: string] : EventHandler;
}

also your test method is invalid as it does not meet EventHandler requirements that forces no parameters on that method.


How about this:

export interface EventMap extends Record<string, EventHandler>{
    [index: string]: EventHandler;
    event1: () => void;
    event2: () => void;
}

now the required indexer exists but you force classes to have event1 and event2.

Rafal
  • 12,391
  • 32
  • 54
  • 2
    Right, but that's not what I need. Adding an index signature there will also not make it typesafe, as there is no restriction on what events one can emit and listen to. There has to be a way to do this correctly. – Sebastian Olsen Nov 26 '18 at 14:10
  • This is what you asked for in your question. EventMap is just a record with index string and EventHandler as result so yes EventMap is enforced. – Rafal Nov 26 '18 at 14:13