Hello! Welcome back.
In our previous lesson, we designed the AppEventMap interface, which acts as a crucial contract for our type-safe event system. We established a clear blueprint that links event names to their specific payload types.
Today, we will build upon that foundation. Our learning goal is to implement the EventBus class itself. Specifically, we will create the class structure, make it generic, and—most importantly—constrain that generic parameter to accept only valid event maps. This step forges the connection between our abstract contract (EventMap) and its concrete implementation (EventBus), setting the stage for true type safety.
1. Scaffolding the Generic EventBus Class
We'll begin by creating the basic structure of our EventBus class. The key is to make it generic so that it can work with any event map, not just the specific AppEventMap we might define for one application.
A video we consulted in the last lesson, "TypeScript: Building a better EventEmitter", provides an excellent walkthrough of this process. Let's watch the relevant segments that focus on creating the generic class structure.
TypeScript: Building a better EventEmitter
This video by Tech Talks with Simon will guide us through scaffolding the class. Pay close attention to how a generic parameter is introduced and then constrained to create a flexible yet type-safe foundation.
Please watch from 01:54 to 06:30. The first part (up to 05:05) shows the initial, non-generic class structure. The second part (from 05:05) introduces the key concept for today's lesson: the EventMap generic parameter and its constraint using extends.
2. The Generic Constraint: Enforcing the Contract
As you saw in the video, the most critical line for our purpose is:
class EventBus<EventMap extends Record<string, any[]>> {
// ... class members
}
Let's break this down:
class EventBus<EventMap>: This declaresEventBusas a generic class.EventMapis a placeholder for a type that will be provided when we instantiate the class (e.g.,new EventBus<AppEventMap>()).extends Record<string, any[]>: This is the generic constraint. It enforces a rule on any type passed asEventMap. It states that the provided type must be compatible withRecord<string, any[]>, which describes an object with string keys and values that are arrays. This perfectly matches the structure of the event map we designed in the last lesson (e.g.,'eventName': [arg1, arg2]).
This constraint is what allows TypeScript to trust that EventMap will always have a shape it can work with, enabling type-safe operations inside the class.
Test your understanding!
Suppose you tried to instantiate the EventBus with a type that doesn't fit the constraint, like new EventBus<string>() or new EventBus<number[]>(). What would happen?
Show answer
TypeScript would raise a compile-time error. For string, the error would state that Type 'string' does not satisfy the constraint 'Record<string, any[]>'. For number[], it would complain that an array doesn't have string index signatures. This immediate feedback is precisely why we use constraints.
For a deeper dive or a refresher on how generic constraints work in TypeScript, the video "Generics: The most intimidating TypeScript feature" offers a concise explanation. Section 6 (07:24 - 10:09) on Generic Constraints is particularly relevant.
3. Defining the Internal State
Now that we have our constrained generic class, we need a place to store the event listeners (or handlers). A private property within the class is the right approach. The type of this property is a fantastic opportunity to apply the mapped types we learned about in the previous lesson.
The video "TypeScript: Building a better EventEmitter" demonstrates a very effective pattern for this. Let's create our initial EventBus.ts file with this structure.
// src/EventBus.ts
export class EventBus<
// The generic EventMap is constrained to be an object with string keys
// and array values, which represent the arguments for a given event.
EventMap extends Record<string, any[]>
> {
// A private property to store the listeners.
// It's a mapped type that ensures the keys are from our EventMap.
private listeners: {
[K in keyof EventMap]?: Set<(...args: EventMap[K]) => void>;
} = {};
// We will implement these methods in the next lesson.
on<K extends keyof EventMap>(event: K, listener: (...args: EventMap[K]) => void): void {
// TODO
}
emit<K extends keyof EventMap>(event: K, ...args: EventMap[K]): void {
// TODO
}
}
Let's analyze the listeners property declaration:
private listeners: ... = {};: It's a private property, inaccessible from outside the class, initialized as an empty object.{ [K in keyof EventMap]?: ... }: This is a mapped type. It iterates over every event nameKin ourEventMap.- The
?makes each property optional. This is crucial because when theEventBusis first created, it has no listeners for any event. Without the?, TypeScript would complain that the empty{}initializer is missing properties for'user:login','user:logout', etc.
- The
Set<(...args: EventMap[K]) => void>: The type for each property is aSetof functions.- Using a
Setis a clean way to store listeners, as it automatically handles duplicates—you can't add the same listener function for the same event twice. - The function signature
(...args: EventMap[K]) => voidis derived directly from ourEventMap. For a keyK,EventMap[K]looks up the corresponding argument tuple, ensuring the listener has the correct parameters.
- Using a
This implementation is robust, leveraging several TypeScript features to create a strong internal structure that is intrinsically linked to the EventMap contract. The article "Type-Safe Event Emitter in TypeScript" by Daniel Afonso provides another excellent, detailed write-up of this exact pattern, which you can refer to for a textual explanation.
Conclusion
In this lesson, you have successfully implemented the core structure of our EventBus. You have created a generic class and, most importantly, applied a constraint to its generic parameter, ensuring it can only be used with a valid event map contract. You also defined its internal listeners property using a mapped type, directly applying the concepts from our previous session.
Key Takeaways:
- An
EventBusshould be a generic class (class EventBus<EventMap>) to remain reusable. - A generic constraint (
extends Record<string, any[]>) is used to enforce the shape of the event map, which is the cornerstone of the class's type safety. - The internal state (e.g., a
listenersobject) should be typed using mapped types ([K in keyof EventMap]) to ensure its structure mirrors the event map. - Using a
Setto store listeners for each event is a practical way to prevent duplicate handler registrations.
With the class structure in place, we are now ready to make it functional. In the next lesson, we will implement the type-safe emit and on methods, bringing our EventBus to life.
Can't find a good explanation? Sign up and we'll make it for you
Sign up