← run

ts-04-event-emitter

1.000
8/8 tests· typing
Challenge · difficulty 4/5
# Typed event emitter

Implement **`solution.ts`** exporting a generic, fully typed event emitter:

```ts
export class EventEmitter<Events extends Record<string, unknown[]>> {
  on<E extends keyof Events>(event: E, fn: (...args: Events[E]) => void): () => void;
  off<E extends keyof Events>(event: E, fn: (...args: Events[E]) => void): void;
  emit<E extends keyof Events>(event: E, ...args: Events[E]): void;
}
```

`Events` maps an event name to the **tuple of argument types** its listeners receive.

- `on(event, fn)` registers a listener and returns an **unsubscribe** function. Calling
  the returned function removes that exact listener.
- `off(event, fn)` removes a previously registered listener. Removing a listener that is
  not registered is a no-op.
- `emit(event, ...args)` calls every listener registered for `event`, **in the order they
  were registered**, passing `args`. Emitting an event with no listeners is a no-op.
- The same function may be registered more than once; each registration is independent.

Example:

```ts
type Events = {
  message: [text: string];
  count: [n: number, label: string];
};

const ee = new EventEmitter<Events>();
const unsub = ee.on("message", (text) => console.log(text));
ee.emit("message", "hi"); // logs "hi"
unsub();
ee.emit("message", "bye"); // nothing logged
```

Keep it fully typed (must pass `tsc --noEmit` in strict mode). Do not use `any` in the
public API.
tests/solution.test.ts
import { test } from "node:test";
import { strict as assert } from "node:assert";
import { EventEmitter } from "./solution.ts";

type Events = {
  message: [text: string];
  count: [n: number, label: string];
};

test("calls a registered listener with args", () => {
  const ee = new EventEmitter<Events>();
  const seen: string[] = [];
  ee.on("message", (text) => seen.push(text));
  ee.emit("message", "hi");
  assert.deepEqual(seen, ["hi"]);
});

test("multiple listeners fire in registration order", () => {
  const ee = new EventEmitter<Events>();
  const order: number[] = [];
  ee.on("message", () => order.push(1));
  ee.on("message", () => order.push(2));
  ee.on("message", () => order.push(3));
  ee.emit("message", "x");
  assert.deepEqual(order, [1, 2, 3]);
});

test("passes multiple typed args", () => {
  const ee = new EventEmitter<Events>();
  let received: [number, string] | undefined;
  ee.on("count", (n, label) => {
    received = [n, label];
  });
  ee.emit("count", 7, "items");
  assert.deepEqual(received, [7, "items"]);
});

test("returned unsubscribe removes the listener", () => {
  const ee = new EventEmitter<Events>();
  const seen: string[] = [];
  const unsub = ee.on("message", (t) => seen.push(t));
  ee.emit("message", "a");
  unsub();
  ee.emit("message", "b");
  assert.deepEqual(seen, ["a"]);
});

test("off removes a specific listener, leaving others", () => {
  const ee = new EventEmitter<Events>();
  const seen: string[] = [];
  const a = (t: string) => seen.push("a:" + t);
  const b = (t: string) => seen.push("b:" + t);
  ee.on("message", a);
  ee.on("message", b);
  ee.off("message", a);
  ee.emit("message", "x");
  assert.deepEqual(seen, ["b:x"]);
});

test("emitting an event with no listeners is a no-op", () => {
  const ee = new EventEmitter<Events>();
  assert.doesNotThrow(() => ee.emit("message", "nobody"));
});

test("off on an unregistered listener is a no-op", () => {
  const ee = new EventEmitter<Events>();
  assert.doesNotThrow(() => ee.off("message", () => {}));
});

test("the same function registered twice fires twice", () => {
  const ee = new EventEmitter<Events>();
  let calls = 0;
  const fn = () => {
    calls += 1;
  };
  ee.on("message", fn);
  ee.on("message", fn);
  ee.emit("message", "x");
  assert.equal(calls, 2);
});
System prompt
You are an expert programmer. Solve the task exactly as specified. Output your solution as fenced code blocks using the required file name(s) and the exact function/type signatures requested. Prefer correctness; do not include prose outside code unless asked.
Proposed solution
```ts
export class EventEmitter<Events extends Record<string, unknown[]>> {
  private listeners = new Map<keyof Events, Array<(...args: any[]) => void>>();

  on<E extends keyof Events>(event: E, fn: (...args: Events[E]) => void): () => void {
    const list = this.listeners.get(event) ?? [];
    list.push(fn);
    this.listeners.set(event, list);

    return () => {
      this.off(event, fn);
    };
  }

  off<E extends keyof Events>(event: E, fn: (...args: Events[E]) => void): void {
    const list = this.listeners.get(event);
    if (!list) return;
    const index = list.indexOf(fn);
    if (index !== -1) {
      list.splice(index, 1);
    }
  }

  emit<E extends keyof Events>(event: E, ...args: Events[E]): void {
    const list = this.listeners.get(event);
    if (!list) return;
    for (const fn of list) {
      fn(...args);
    }
  }
}
```

Test output (stdout)

TAP version 13
# Subtest: calls a registered listener with args
ok 1 - calls a registered listener with args
  ---
  duration_ms: 0.660752
  type: 'test'
  ...
# Subtest: multiple listeners fire in registration order
ok 2 - multiple listeners fire in registration order
  ---
  duration_ms: 0.08268
  type: 'test'
  ...
# Subtest: passes multiple typed args
ok 3 - passes multiple typed args
  ---
  duration_ms: 0.05773
  type: 'test'
  ...
# Subtest: returned unsubscribe removes the listener
ok 4 - returned unsubscribe removes the listener
  ---
  duration_ms: 0.062531
  type: 'test'
  ...
# Subtest: off removes a specific listener, leaving others
ok 5 - off removes a specific listener, leaving others
  ---
  duration_ms: 0.06881
  type: 'test'
  ...
# Subtest: emitting an event with no listeners is a no-op
ok 6 - emitting an event with no listeners is a no-op
  ---
  duration_ms: 0.07843
  type: 'test'
  ...
# Subtest: off on an unregistered listener is a no-op
ok 7 - off on an unregistered listener is a no-op
  ---
  duration_ms: 0.04482
  type: 'test'
  ...
# Subtest: the same function registered twice fires twice
ok 8 - the same function registered twice fires twice
  ---
  duration_ms: 0.062321
  type: 'test'
  ...
1..8
# tests 8
# suites 0
# pass 8
# fail 0
# cancelled 0
# skipped 0
# todo 0
# duration_ms 74.517656
Reasoning (chain-of-thought)
The user wants a fully typed event emitter in TypeScript. Let me implement this step by step.

Key requirements:
1. Generic `EventEmitter<Events extends Record<string, unknown[]>>`
2. `on` registers a listener, returns unsubscribe function
3. `off` removes a listener (no-op if not registered)
4. `emit` calls all listeners in registration order
5. Same function can be registered multiple times
6. Fully typed, no `any` in public API

Let me think about the implementation:

- Store listeners as a Map from event name to array of listener functions
- For `on`, push the listener to the array and return a function that removes it
- For `off`, find and remove the listener from the array
- For `emit`, iterate through listeners and call them

Type considerations:
- `Events` is `Record<string, unknown[]>` where each value is a tuple type
- `E extends keyof Events` ensures we only use valid event names
- `Events[E]` gives us the tuple of argument types for that event
- Listener type is `(...args: Events[E]) => void`

Let me write the implementation:

```ts
export class EventEmitter<Events extends Record<string, unknown[]>> {
  private listeners = new Map<keyof Events, Array<(...args: unknown[]) => void>>();

  on<E extends keyof Events>(event: E, fn: (...args: Events[E]) => void): () => void {
    const list = this.listeners.get(event) ?? [];
    list.push(fn as (...args: unknown[]) => void);
    this.listeners.set(event, list);

    return () => {
      this.off(event, fn);
    };
  }

  off<E extends keyof Events>(event: E, fn: (...args: Events[E]) => void): void {
    const list = this.listeners.get(event);
    if (!list) return;
    const index = list.indexOf(fn as (...args: unknown[]) => void);
    if (index !== -1) {
      list.splice(index, 1);
    }
  }

  emit<E extends keyof Events>(event: E, ...args: Events[E]): void {
    const list = this.listeners.get(event);
    if (!list) return;
    for (const fn of list) {
      fn(...args);
    }
  }
}
```

Wait, there's a type issue. The `listeners` map stores functions as `(...args: unknown[]) => void`, but when we call them in `emit`, we need to ensure the types match.

Actually, let me reconsider. The issue is that `fn(...args)` where `fn` is typed as `(...args: unknown[]) => void` and `args` is `Events[E]` should work because `Events[E]` extends `unknown[]`.

But there's a subtlety: when we store `fn` as `(...args: unknown[]) => void`, TypeScript might complain about the cast. Let me think...

Actually, `(...args: Events[E]) => void` is assignable to `(...args: unknown[]) => void` because `Events[E]` extends `unknown[]`. So the cast should be safe.

When calling `fn(...args)` where `fn` is `(...args: unknown[]) => void` and `args` is `Events[E]`, this should work because `Events[E]` extends `unknown[]`.

Let me verify this compiles correctly. Actually, there might be an issue with strict function types. Let me think more carefully.

In TypeScript with strict mode:
- `(...args: Events[E]) => void` is a subtype of `(...args: unknown[]) => void` because `Events[E]` extends `unknown[]` and function parameters are contravariant. Wait, actually that's not quite right.

For function types, if `A extends B`, then `(...args: B) => void` extends `(...args: A) => void` (contravariance in parameters). So `(...args: unknown[]) => void` extends `(...args: Events[E]) => void` because `Events[E] extends unknown[]`.

So we can't directly assign `(...args: Events[E]) => void` to `(...args: unknown[]) => void` without a cast.

Let me use a different approach. Instead of storing as `unknown[]`, I can use a more generic storage:

```ts
private listeners = new Map<keyof Events, Array<(...args: any[]) => void>>();
```

But the requirement says no `any` in the public API. The `listeners` field is private, so using `any` there should be fine. But let me try to avoid it entirely.

Actually, let me reconsider. The simplest approach that works:

```ts
export class EventEmitter<Events extends Record<string, unknown[]>> {
  private listeners = new Map<keyof Events, Array<(...args: unknown[]) => void>>();

  on<E extends keyof Events>(event: E, fn: (...args: Events[E]) => void): () => void {
    const list = this.listeners.get(event) ?? [];
    list.push(fn as (...args: unknown[]) => void);
    this.listeners.set(event, list);

    return () => {
      this.off(event, fn);
    };
  }

  off<E extends keyof Events>(event: E, fn: (...args: Events[E]) => void): void {
    const list = this.listeners.get(event);
    if (!list) return;
    const index = list.indexOf(fn as (...args: unknown[]) => void);
    if (index !== -1) {
      list.splice(index, 1);
    }
  }

  emit<E extends keyof Events>(event: E, ...args: Events[E]): void {
    const list = this.listeners.get(event);
    if (!list) return;
    for (const fn of list) {
      (fn as (...args: Events[E]) => void)(...args);
    }
  }
}
```

Hmm, but this uses casts which might not be ideal. Let me think of a cleaner approach.

Actually, I can use a different storage structure. Instead of storing all listeners in one map with a common type, I can use a more type-safe approach:

```ts
export class EventEmitter<Events extends Record<string, unknown[]>> {
  private listeners = new Map<keyof Events, Array<(...args: unknown[]) => void>>();

  on<E extends keyof Events>(event: E, fn: (...args: Events[E]) => void): () => void {
    const list = this.listeners.get(event) ?? [];
    list.push(fn as (...args: unknown[]) => void);
    this.listeners.set(event, list);

    return () => {
      this.off(event, fn);
    };
  }

  off<E extends keyof Events>(event: E, fn: (...args: Events[E]) => void): void {
    const list = this.listeners.get(event);
    if (!list) return;
    const index = list.indexOf(fn as (...args: unknown[]) => void);
    if (index !== -1) {
      list.splice(index, 1);
    }
  }

  emit<E extends keyof Events>(event: E, ...args: Events[E]): void {
    const list = this.listeners.get(event);
    if (!list) return;
    for (const fn of list) {
      fn(...args);
    }
  }
}
```

Wait, in `emit`, `fn` is `(...args: unknown[]) => void` and `args` is `Events[E
…[12619 chars elided]…
``ts
export class EventEmitter<Events extends Record<string, unknown[]>> {
  private listeners = new Map<keyof Events, Array<(...args: any[]) => void>>();

  on<E extends keyof Events>(event: E, fn: (...args: Events[E]) => void): () => void {
    const list = this.listeners.get(event) ?? [];
    list.push(fn);
    this.listeners.set(event, list);

    return () => {
      this.off(event, fn);
    };
  }

  off<E extends keyof Events>(event: E, fn: (...args: Events[E]) => void): void {
    const list = this.listeners.get(event);
    if (!list) return;
    const index = list.indexOf(fn);
    if (index !== -1) {
      list.splice(index, 1);
    }
  }

  emit<E extends keyof Events>(event: E, ...args: Events[E]): void {
    const list = this.listeners.get(event);
    if (!list) return;
    for (const fn of list) {
      fn(...args);
    }
  }
}
```

This should work correctly and pass `tsc --noEmit` in strict mode. The public API is fully typed without `any`.

Let me double-check by mentally running through the example:

```ts
type Events = {
  message: [text: string];
  count: [n: number, label: string];
};

const ee = new EventEmitter<Events>();
const unsub = ee.on("message", (text) => console.log(text));
ee.emit("message", "hi"); // logs "hi"
unsub();
ee.emit("message", "bye"); // nothing logged
```

1. `ee.on("message", (text) => console.log(text))`:
   - `E` is `"message"`, `Events["message"]` is `[text: string]`
   - `fn` is `(text: string) => void`
   - Push `fn` to listeners for "message"
   - Return unsubscribe function

2. `ee.emit("message", "hi")`:
   - `E` is `"message"`, `Events["message"]` is `[text: string]`
   - `args` is `["hi"]` which matches `[text: string]`
   - Call all listeners for "message" with `["hi"]`
   - Logs "hi"

3. `unsub()`:
   - Calls `ee.off("message", fn)`
   - Removes `fn` from listeners for "message"

4. `ee.emit("message", "bye")`:
   - No listeners for "message", so nothing happens

This matches the expected behavior.

I'm confident this solution is correct. Let me output it.