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.
- All episodes are in the TypeScript from Zero playlist.
- Versions used: Node.js 26.13.0, npm 11.20.0, TypeScript 7.0.2.
Setup
We start in the terminal and open the project in VS Code:
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:
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");$ node src/events.ts
nothing happened2. 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:
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");$ node src/events.ts
nothing happened
Added: BUY MILK
Done: 1 at 2026-11-023. 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:
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);$ 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$ 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:164. 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:
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);$ 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:165. Fix
We delete that line as well, and tsc is quiet.
src/events.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");$ npx tsc --noEmitRecap
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.