Type-Safe Event Emitters in TypeScript
You’ve been there: a typo in an event name, a mismatched payload, and suddenly a listener silently never fires. The bug takes 30 minutes to track down, and the fix is one character. Typed event emitters in TypeScript eliminate this entire category of problem by moving the error from runtime to compile time.
This article shows you how the TypeScript event map pattern works, when to build your own abstraction versus using what already exists, and a few practical patterns worth knowing.
Key Takeaways
- An event map interface acts as a single source of truth, tying each event name to its exact payload type at compile time.
- A lightweight generic emitter class using
K extends keyof TEventscatches typos and shape mismatches before your code ever runs. - Modern
@types/nodealready supports typed event maps, so a custom class isn’t always necessary. - Always pair listener registration with explicit cleanup to prevent memory leaks in long-lived services and UI components.
Why Type-Safe EventEmitters Matter
The untyped approach looks like this:
import { EventEmitter } from 'events'
const emitter = new EventEmitter()
emitter.on('userLoggedIn', (user) => {
console.log(user.name) // user is `any`
})
emitter.emit('userLoggedin', { name: 'Alice' }) // typo — no error
In this untyped usage, TypeScript won’t catch the casing difference between userLoggedIn and userLoggedin. The listener never fires, and nothing tells you why.
The TypeScript Event Map Pattern
The fix starts with a simple interface — an event map — where each key is an event name and each value is its payload type:
interface UserEvents {
userLoggedIn: { userId: string, timestamp: Date }
userLoggedOut: { userId: string }
profileUpdated: { userId: string, changes: Record<string, unknown> }
}
This interface becomes the single source of truth for everything your emitter can do.
Building a Strongly Typed Custom Emitter
Here’s a minimal but complete typed emitter class:
type EventCallback<T> = (payload: T) => void
class TypedEventEmitter<TEvents extends Record<string, unknown>> {
private listeners: {
[K in keyof TEvents]?: EventCallback<TEvents[K]>[]
} = {}
on<K extends keyof TEvents>(event: K, callback: EventCallback<TEvents[K]>): void {
(this.listeners[event] ??= []).push(callback)
}
off<K extends keyof TEvents>(event: K, callback: EventCallback<TEvents[K]>): void {
const cbs = this.listeners[event]
if (cbs) this.listeners[event] = cbs.filter(cb => cb !== callback)
}
emit<K extends keyof TEvents>(event: K, payload: TEvents[K]): void {
this.listeners[event]?.forEach(cb => cb(payload))
}
once<K extends keyof TEvents>(event: K, callback: EventCallback<TEvents[K]>): void {
const wrapper: EventCallback<TEvents[K]> = (payload) => {
this.off(event, wrapper)
callback(payload)
}
this.on(event, wrapper)
}
}
The key mechanism is K extends keyof TEvents. When you call emit('userLoggedIn', ...), TypeScript narrows K to exactly 'userLoggedIn' and checks that the payload matches { userId: string, timestamp: Date }. A typo or wrong shape becomes a compile error.
const emitter = new TypedEventEmitter<UserEvents>()
emitter.on('userLoggedIn', ({ userId, timestamp }) => {
console.log(userId, timestamp) // fully typed
})
emitter.emit('userLoggedin', { userId: '1', timestamp: new Date() }) // ❌ compile error
emitter.emit('userLoggedIn', { userId: '1' }) // ❌ missing timestamp
Discover how at OpenReplay.com.
Using Node.js EventEmitter with Type Safety
Modern @types/node exposes EventEmitter as a generic class, so you can often get typed events directly without writing a wrapper.
import { EventEmitter } from 'events'
interface AppEvents {
'user:login': [userId: string, email: string]
'data:sync': [recordCount: number]
}
const emitter = new EventEmitter<AppEvents>()
emitter.on('user:login', (userId, email) => {
console.log(userId, email)
})
emitter.emit('data:sync', 42)
One important caveat: Node’s event system is synchronous by default — listeners run in registration order before emit returns. Also, the 'error' event has special runtime behavior: if emitted with no listener attached, Node throws an uncaught exception. Helper utilities like events.once() may not fully preserve your event map’s types, so type safety can degrade in those cases.
Alternatives and Libraries
In browser contexts, EventTarget with CustomEvent is the native option, but typing custom EventTarget subclasses in TypeScript is awkward — the generic constraints don’t align cleanly with CustomEvent<T>.
Libraries like mitt and eventemitter3 offer typed emitters out of the box and are worth considering if you want a battle-tested implementation rather than rolling your own. They’re optional, not required — the pattern itself is straightforward enough to own directly.
Practical Cleanup: Avoiding Memory Leaks
Always remove listeners when they’re no longer needed. Forgotten listeners are one of the most common sources of memory leaks in event-driven code:
const handler = ({ userId }: { userId: string }) => console.log(userId)
emitter.on('userLoggedOut', handler)
// Later, in cleanup:
emitter.off('userLoggedOut', handler)
In React, pair this with useEffect cleanup. In any long-lived service, document which component owns listener teardown.
Conclusion
The TypeScript event map pattern costs you one interface definition upfront. What you get back is compile-time enforcement of event names and payload shapes across your entire codebase — no more silent mismatches, no more any-typed callbacks, and full IntelliSense on every listener. For any system where events cross module boundaries, that trade is worth making immediately.
FAQs
Yes. Because your typed emitter still extends or uses Node's EventEmitter API, it remains compatible with any code that accepts an EventEmitter instance. The type constraints are enforced only at compile time and produce no runtime overhead, so downstream libraries continue to work as expected.
Define the event's value as void in your event map, for example statusCheck: void. Then adjust your emit signature to make the payload parameter optional when the type is void. This keeps the call site clean — you can call emit('statusCheck') without passing an argument.
TypeScript will flag the conflict wherever the types are incompatible. The safest approach is to define a single shared event map interface in a common module and import it everywhere. This keeps all producers and consumers aligned against one source of truth.
It can work well for lightweight, in-page messaging between micro-frontends. However, as the number of independent teams grows, consider pairing the pattern with a shared schema package or code generation step so the event map stays synchronized across separately deployed applications.
Complete picture for complete understanding
Capture every clue your frontend is leaving so you can instantly get to the root cause of any issue with OpenReplay — the open-source session replay tool for developers. Self-host it in minutes, and have complete control over your customer data.
Check our GitHub repo and join the thousands of developers in our community.