M Logo
๐Ÿ“ TypeScript from Zero ยท Part 39 of 50

TypeScript EventEmitter with a Typed Event Map (node:events)

November 6, 2026

Welcome to episode 39 of TypeScript from Zero, a series for complete beginners (no JavaScript needed). Welcome back to TypeScript from zero! Many programs work with events: something happens, and other parts want to know. Node has the event emitter for this. Today we make it type safe. A new file called events.ts in the tasks project. Every code block below is the exact code and output from the video, ready to copy.

Setup

We start in the terminal and open the project in VS Code:

bash
cd tasks
touch src/events.ts
code .

1. Plain

An emitter has two sides. On registers a listener for a named event, and emit fires the event with some data. Here the listener waits for added. But the emit has a typo, a d too many. Nothing happens, and nothing tells us. The listener is never called, and the program carries on. tsc is silent too, because a plain emitter accepts any name, and any data.

src/events.ts:

ts
import { EventEmitter } from "node:events";

const plain = new EventEmitter();
plain.on("added", (title) => console.log("Added:", title));
plain.emit("addded", "Buy milk");
console.log("nothing happened");
text
$ node src/events.ts
nothing happened

2. Event map

The fix is a generic type. We describe all events in one type: the name of each event, and the list of its arguments, with a label for each. Added carries a title, a string. Done carries an id, a number, and a time, a string. The emitter takes this type in angle brackets. Now the listeners know their parameters: the title is a string, so to upper case is fine. Both events fire, and the output is right.

src/events.ts:

ts
import { EventEmitter } from "node:events";

const plain = new EventEmitter();
plain.on("added", (title) => console.log("Added:", title));
plain.emit("addded", "Buy milk");
console.log("nothing happened");
type TaskEvents = {
  added: [title: string];
  done: [id: number, at: string];
};
const bus = new EventEmitter<TaskEvents>();
bus.on("added", (title) => console.log("Added:", title.toUpperCase()));
bus.on("done", (id, at) => console.log("Done:", id, "at", at));
bus.emit("added", "Buy milk");
bus.emit("done", 1, "2026-11-02");
text
$ node src/events.ts
nothing happened
Added: BUY MILK
Done: 1 at 2026-11-02

3. Wrong data

Now we send the wrong kind of data: a number as the title. At runtime that is a crash, because a number has no to upper case. tsc stops it before: argument of type number is not assignable to parameter of type string. TS2345.

src/events.ts:

ts
import { EventEmitter } from "node:events";

const plain = new EventEmitter();
plain.on("added", (title) => console.log("Added:", title));
plain.emit("addded", "Buy milk");
console.log("nothing happened");
type TaskEvents = {
  added: [title: string];
  done: [id: number, at: string];
};
const bus = new EventEmitter<TaskEvents>();
bus.on("added", (title) => console.log("Added:", title.toUpperCase()));
bus.on("done", (id, at) => console.log("Done:", id, "at", at));
bus.emit("added", "Buy milk");
bus.emit("done", 1, "2026-11-02");
bus.emit("added", 42);
text
$ node src/events.ts
nothing happened
Added: BUY MILK
Done: 1 at 2026-11-02
file:///home/mah/tasks/src/events.ts:12
bus.on("added", (title) => console.log("Added:", title.toUpperCase()));
                                                       ^

TypeError: title.toUpperCase is not a function
    at EventEmitter.<anonymous> (file:///home/mah/tasks/src/events.ts:12:56)
    at EventEmitter.emit (node:events:514:20)
    at file:///home/mah/tasks/src/events.ts:16:5
    at ModuleJob.run (node:internal/modules/esm/module_job:569:25)
    at async node:internal/modules/esm/loader:650:26
    at async asyncRunEntryPointWithESMLoader (node:internal/modules/run_main:105:5)

Node.js v26.13.0
text
$ npx tsc --noEmit
src/events.ts:16:19 - error TS2345: Argument of type 'number' is not assignable to parameter of type 'string'.

16 bus.emit("added", 42);
                     ~~

Found 1 error in src/events.ts:16

4. Fix

We delete the line. The data of a known event is checked in both directions. Now we forget the second piece of data, the time. tsc says: expected three arguments, but got two. TS2554. Every event must carry everything that its list promises.

src/events.ts:

ts
import { EventEmitter } from "node:events";

const plain = new EventEmitter();
plain.on("added", (title) => console.log("Added:", title));
plain.emit("addded", "Buy milk");
console.log("nothing happened");
type TaskEvents = {
  added: [title: string];
  done: [id: number, at: string];
};
const bus = new EventEmitter<TaskEvents>();
bus.on("added", (title) => console.log("Added:", title.toUpperCase()));
bus.on("done", (id, at) => console.log("Done:", id, "at", at));
bus.emit("added", "Buy milk");
bus.emit("done", 1, "2026-11-02");
bus.emit("done", 1);
text
$ npx tsc --noEmit
src/events.ts:16:5 - error TS2554: Expected 3 arguments, but got 2.

16 bus.emit("done", 1);
       ~~~~

Found 1 error in src/events.ts:16

5. Fix

We delete that line as well, and tsc is quiet.

src/events.ts:

ts
import { EventEmitter } from "node:events";

const plain = new EventEmitter();
plain.on("added", (title) => console.log("Added:", title));
plain.emit("addded", "Buy milk");
console.log("nothing happened");
type TaskEvents = {
  added: [title: string];
  done: [id: number, at: string];
};
const bus = new EventEmitter<TaskEvents>();
bus.on("added", (title) => console.log("Added:", title.toUpperCase()));
bus.on("done", (id, at) => console.log("Done:", id, "at", at));
bus.emit("added", "Buy milk");
bus.emit("done", 1, "2026-11-02");
bash
$ npx tsc --noEmit

Recap

Today: the event emitter, why a plain one hides typos, and an event map that types the data of every event, for the listeners and for emit. One honest note: emit still accepts a name that is not in the map, so keep the names in constants. Next episode: Part 5, advanced types, starting with key of and type of.

ยฉ 2026 All Rights Reserved By Mahmudul Alam