1

Here's a design of an event emitter:

type EventMap = Record<string, any>;

type EventKey<T extends EventMap> = string & keyof T;
type EventReceiver<T> = (params: T) => void;

interface Emitter<T extends EventMap> {
  on<K extends EventKey<T>>
    (eventKey: K, fn: EventReceiver<T[K]>): void;
  emit<K extends EventKey<T>>
    (eventKey: K, params: T[K]): void;
}

// example usage
const emitter = eventEmitter<{
  data: Buffer | string;
  end: undefined;
}>();

// OK!
emitter.on('data', (x: string) => {});

// Error! 'string' is not assignable to 'number'
emitter.on('data', (x: number) => {});

The design works but two things I don't understand:

  1. EventKey: why does it need to add a string & here?

  2. Why do we need generics K with on and emit methods, can't the type for eventKey param simply be keyof T?

Many thanks in advance.

Stanley Luo
  • 3,689
  • 5
  • 34
  • 64

1 Answers1

2

EventKey: why does it need to add a string & here?

The intersection string & ... is used to make sure that only keys that are actually of type string can be used to define the EventKey type.

Even though EventMap is defined as Record<string,any> you can still assign an object with non-string properties to it:

const a: EventMap = {
  foo: 'bar',
  100: 'baz', // whoops, number property
}

Why? Because objects can have excess properties.

So in such a case, string & keyof T would result to never for rogue properties that are not of type string.

Can't the type for eventKey param simply be keyof T?

This is to allow keys defined as union types, which do extend string (See also this answer):

type EnumK = 'alpha' | 'beta'
type MyObject = {[P in EnumK]: string }

const obj: SomeObj = {
  alpha: 'foo',
  beta: 'bar'
}

Here the type of obj's keys is an extension of string, namely the union between the string literal types alpha and beta. So by using K extends ... you capture this relation and ensure type safety in type indexing T[K].

blackgreen
  • 34,072
  • 23
  • 111
  • 129