RizTech Academy logo
RizTech Academy
Asynchronous TypeScriptLesson 4 of 530 min

Typing callbacks, event emitters and streams

Not all asynchronous code uses promises. A lot of it — event listeners, timers, streams, older APIs — uses callbacks: functions you hand over to be called later, sometimes many times. Typing these well, and knowing how to wrap them into promises, is the other half of async TypeScript. This lesson covers typed callbacks, event emitters, and converting the callback world into the promise world.

Typed callbacks

You met callback types in the functions module; async callbacks are the same idea, typed by what they receive:

setTimeout(() => {
  console.log("done");
}, 1000);      // the callback takes no arguments -> () => void

const numbers = [1, 2, 3];
numbers.forEach((n, i) => {
  console.log(`${i}: ${n}`);   // n is number, i is number — typed from the array
});

setTimeout's callback is () => void; forEach's callback is (value: T, index: number) => void. When you write your own function that takes a callback, you type the callback's parameters so the caller gets full checking:

function onData(handler: (chunk: string) => void): void {
  // ... calls handler(someString) when data arrives, possibly many times
}

onData((chunk) => chunk.toUpperCase());   // chunk inferred as string

The key difference from a promise: a callback can be called many times (a promise resolves once). An event handler fires on every click; a stream's onData fires per chunk. So callbacks model repeated async events, where a promise models a single future value. Knowing which you have tells you which tool to reach for.

The Node-style error-first callback

A convention you will meet constantly in Node.js APIs is the error-first callback: the callback's first parameter is an error (or null if none), and the result follows:

function readFile(
  path: string,
  callback: (error: Error | null, data?: string) => void
): void {
  // calls callback(null, "contents") on success, or callback(new Error(...)) on failure
}

readFile("file.txt", (error, data) => {
  if (error) {
    console.error(error.message);   // error narrowed to Error
    return;
  }
  console.log(data);   // data is string | undefined — present on success
});

The type (error: Error | null, data?: string) => void captures the convention: an error that is either an Error or null, and optional data. You check the error first (narrowing to Error), and if there is none, use the data. This pattern is everywhere in older Node code, and typing it correctly — the error possibly-null, the data optional — is a common real-world task. It is also, frankly, awkward, which is why modern code converts it to a promise.

Wrapping a callback into a promise

The bridge from the callback world to the pleasant async/await world is wrapping a callback-based function in a new Promise:

function readFileAsync(path: string): Promise<string> {
  return new Promise((resolve, reject) => {
    readFile(path, (error, data) => {
      if (error) reject(error);
      else if (data !== undefined) resolve(data);
      else reject(new Error("No data"));
    });
  });
}

// now you can use it with async/await:
async function main() {
  const contents = await readFileAsync("file.txt");   // string — clean!
}

readFileAsync wraps the error-first callback: on error, reject; on success, resolve with the data. The return type Promise<string> means callers get a clean, awaitable promise. This "promisify" pattern is how you modernise callback APIs — and Node provides util.promisify to do it automatically for error-first callbacks. The lesson: when you meet a callback-based async API, wrap it in a promise so the rest of your code can use async/await.

Typing an event emitter

Events — a value that fires repeatedly over time — are typed by describing which events exist and what each carries. A well-typed emitter uses a map from event name to payload type:

type Events = {
  message: string;
  error: Error;
  close: void;
};

class TypedEmitter<E> {
  on<K extends keyof E>(event: K, handler: (payload: E[K]) => void): void { /* ... */ }
  emit<K extends keyof E>(event: K, payload: E[K]): void { /* ... */ }
}

const emitter = new TypedEmitter<Events>();
emitter.on("message", (msg) => msg.toUpperCase());   // msg is string — from Events["message"]
emitter.on("error", (err) => err.message);            // err is Error
emitter.emit("message", "hello");                     // fine
emitter.emit("message", 42);                          // error: number is not string

Look at the generics: on<K extends keyof E>(event: K, handler: (payload: E[K]) => void) uses keyof and indexed access (E[K]) from the advanced-types module — the handler's payload type is derived from the event name. So on("message", ...) gives a string handler, on("error", ...) gives an Error handler, and emit("message", 42) is a type error because message's payload is string. This is a genuinely elegant, fully type-safe event system, and it shows the advanced-types tools paying off in real async code. Many libraries (and TypeScript's own DOM types for addEventListener) work exactly this way.

The DOM's addEventListener, briefly

The browser's addEventListener is a typed event system you use constantly, and it works by the same principle — the event name determines the event object's type:

button.addEventListener("click", (event) => {
  event.clientX;        // event is MouseEvent — has clientX
});

input.addEventListener("input", (event) => {
  event.target;         // event is Event
});

addEventListener("click", ...) gives a MouseEvent; "input" gives an Event — the string narrows the event type, using overloads and a mapping under the hood (the same idea as the TypedEmitter above). You do not write this typing, but you benefit from it every time, and now you understand how it works.

Check your work

How callbacks differ from promises. A callback can be called many times (repeated events); a promise resolves once (a single future value).

How to type a callback. By its parameters — (chunk: string) => void — so the caller gets full checking.

The error-first callback convention. The first parameter is Error | null, the result follows (often optional); check the error first, then use the data.

How to modernise a callback API. Wrap it in new Promise (promisify) — reject on error, resolve on success — so callers can use async/await.

How to type an event emitter. A map from event name to payload type, plus generics using keyof and indexed access (E[K]) so the handler's payload is derived from the event name.

What makes the typed emitter safe. on<K extends keyof E>(event: K, handler: (p: E[K]) => void) — the event name determines the payload type; a wrong payload is a compile error.

How addEventListener is typed. The event name determines the event object's type ("click" → MouseEvent) — the same event-name-to-payload principle.

Practice

  1. Type a function taking a (chunk: string) => void callback and call it. Confirm the callback parameter is typed.
  2. Write an error-first callback type (error: Error | null, data?: string) => void and handle it — narrow the error, then use the data.
  3. Wrap that error-first function into a Promise<string> and use it with await.
  4. Build the TypedEmitter<E> with on/emit using keyof E and E[K]. Confirm on("message", ...) gives a string handler and emit("message", 42) errors.
  5. Add a new event to the Events map and confirm it is immediately available and typed.
  6. Add a click listener to a DOM element (or reason about it) and confirm the event is a MouseEvent.
  7. For three async APIs, decide whether each is promise-shaped (once) or callback/event-shaped (repeated), and justify.

Official documentation

Next: errors in async code, and unknown in catch.

Stuck on this lesson?

Being stuck is part of it — but being stuck alone for three days is not. Our internship programme pairs this curriculum with code review and one-to-one help from working developers, and it is free.

About the internship