2

I'm trying to get better at Typescript types, and have a need for a type safe event emitter. I've tried many different ways now but I just can't seem to get the types to resolve quite right. Can you spot where I'm making a mistake?

In the example below I have an "Events" type that maps event names to the arguments that must be passed along with that event. So if I emit "Foo" I have to also pass a "bar" string, and the listener should know there's a "bar" property to read.

interface Events {
  Foo: {
    bar: string;
  }
}

type EventKeys = keyof Events

class Emitter {
  ...
  emit<K extends EventKeys> (title: K, value: Events[K]): void {
    // With this signature I want to require if the caller specifies a title of "Foo"
    // then they must specify value as "{bar: string}". This part looks to work great!
    this.emitter.emit("connection", [title, value])
  }

  public on (listener: any): void {
    // I use "any" here because this part of the code isn't super relevant to this example
    this.emitter.on('connection', listener.event.bind(f))
  }
}

class Listener {
  ...
  event<K extends EventKeys> ([title, value]: [title: K, value: Events[K]]): void {
    switch(title) {
      case "Foo":
        console.log(value)
        // Here "value" is of type "Events[K]", 
        // which I take to mean it should know it's type "Events[Foo]"
        // or actually "{bar: string}", 
        // but I don't get the autocompletion I expect.

        break
    }
  }
}

Is it not possible to get {bar: string} from generics like Events[K]?

1 Answer 1

1

Here is something that works how you want it. emit() is type-safe now, enabling you to press tab for autocompletions of possible event names. Requires TypeScript 4+.

Demo: https://repl.it/@chvolkmann/Typed-EventEmitter

import { EventEmitter } from 'events'

interface EventTree {
  Connected: {
    foo: string;
  };
}
type EventName = keyof EventTree;

type FormattedEvent<E extends EventName> = [E, EventTree[E]];

class MyListener {
  handleEvent<E extends EventName>([event, args]: FormattedEvent<E>) {
    console.log(`Event "${event}" happened with args`, args);
  }
}

class MyEmitter {
  protected emitter: EventEmitter

  constructor() {
    this.emitter = new EventEmitter()
  }

  emit<E extends EventName>(name: E, args: EventTree[E]) {
    this.emitter.emit('connection', [name, args]);
  }

  registerListener(listener: MyListener) {
    // The typing here is a bit overkill, but for demostration purposes:
    const handler = <E extends EventName>(fEvent: FormattedEvent<E>) => listener.handleEvent(fEvent)
    this.emitter.on('connection', handler)
  }
}
const emitter = new MyEmitter()
emitter.registerListener(new MyListener())

// This supports autocompletion
// Try emitter.emit(<TAB>
emitter.emit('Connected', { foo: 'bar' })

// TypeScript won't compile these examples which have invalid types

// > TS2345: Argument of type '"something else"' is not assignable to parameter of type '"Connected"'.
// emitter.emit('something else', { foo: 'bar' })

// > TS2345: Argument of type '{ wrong: string; }' is not assignable to parameter of type '{ foo: string; }'.
// emitter.emit('Connected', { wrong: 'type' })
Sign up to request clarification or add additional context in comments.

Comments

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Start asking to get answers

Find the answer to your question by asking.

Ask question

Explore related questions

See similar questions with these tags.