Skip to main content

Someone wanted to learn this too, so Grasp built them a personal learning path.

You can keep exploring this one or create your own

Type-Safe Event Maps with Mapped Types

Hello! Welcome to the first lesson in our "Type-Safe Event Bus" module.

In our journey to build a modern JavaScript framework, we've laid a lot of groundwork, from setting up the project to building the core observable architecture. Now, we're going to tackle a common and powerful pattern: a type-safe event bus. This component is crucial for enabling different parts of an application to communicate without being tightly coupled.

This lesson focuses on the absolute foundation of our event bus: designing a robust and type-safe event map. Your goal is to learn how to use TypeScript's mapped types to create an interface that strictly links event names (like 'user:login') to their specific data payloads (like { userId: string, name: string }). Getting this right is what will allow the TypeScript compiler to catch bugs for us and provide a great developer experience with autocompletion.

The Problem with Traditional Event Emitters

Many JavaScript environments, like Node.js, provide an EventEmitter class. While powerful, it traditionally lacks type safety. You can emit any event with any data, and listen for events that may not even exist. This often leads to runtime errors that could have been caught at compile time.

To see this problem in action, let's watch a short segment from a video by "Tech Talks with Simon", which clearly demonstrates the pitfalls of a non-typed event emitter.

TypeScript: Building a better EventEmitter

This video provides an excellent introduction to the problem we're solving. It uses the standard Node.js EventEmitter to show why a lack of type safety can be problematic.

Please watch from the beginning until 02:04. Notice how you can pass any string as an event name and any data as a payload, with no compiler checks or autocompletion to help.

As you saw, the lack of a clear contract between event producers and consumers makes the system brittle. Our goal is to create a system where this contract is explicit and enforced by the TypeScript compiler.

The Solution: An Event Map Contract

The solution is to define a "map" or "contract" that describes all possible events and their corresponding argument types. We'll then use this map to provide strong types for our on and emit methods.

The core idea is to define a simple interface where keys are the event names (string literals) and values are tuples representing the arguments for that event's listener function.

Here's a conceptual example:

interface AppEventMap {
  'user:login': [user: { id: string; name: string }];
  'user:logout': [userId: string];
  'system:ready': []; // An event with no arguments
}

With this AppEventMap, we can now design a generic EventEmitter class that uses it to ensure type safety. The video we started watching explains exactly how to achieve this using generics.

TypeScript: Building a better EventEmitter

Now for the solution. This next segment demonstrates how to use a generic EventMap type to make the EventEmitter type-safe.

First, watch from 05:05 to 07:31. This is the most critical part, explaining how keyof EventMap and indexed access types (EventMap[K]) are used to link event names to their arguments. Then, skip to 10:03 and watch until 12:38 to see the practical benefits: autocompletion for event names and type-checking for payloads.

The key takeaways from the video are the TypeScript features that make this possible:

  • Generics: The class is made generic, class EventEmitter<T extends ...>.
  • keyof operator: K extends keyof T constrains the event name to be one of the keys from our map.
  • Indexed Access Types: ...args: T[K] looks up the argument tuple type for a given event key K.

This gives us a class signature that looks something like this:

class TypedEventEmitter<EventMap extends Record<string, any[]>> {
  on<EventName extends keyof EventMap>(
    event: EventName,
    listener: (...args: EventMap[EventName]) => void
  ) {
    // ... implementation
  }

  emit<EventName extends keyof EventMap>(
    event: EventName,
    ...args: EventMap[EventName]
  ) {
    // ... implementation
  }
}

// And you would use it like this:
const emitter = new TypedEventEmitter<AppEventMap>();

emitter.on('user:login', (user) => {
  // `user` is correctly typed as `{ id: string; name: string }`
  console.log(user.name); 
});

// The compiler would flag these lines as errors:
// emitter.on('non-existent-event', () => {}); // Error: 'non-existent-event' is not in AppEventMap
// emitter.emit('user:logout', { id: '123' }); // Error: 'user:logout' expects a string, not an object
Test your understanding!

Imagine you need to add a new event to our AppEventMap. The event should be named 'notification:show' and it should carry a payload consisting of a message (string) and a duration (number, in milliseconds). How would you modify the AppEventMap interface to include this new event?

Show answer

You would add a new property to the interface. The key is the event name 'notification:show', and the value is a tuple [payload: { message: string; duration: number }]. The full interface would look like this:

interface AppEventMap {
  'user:login': [user: { id: string; name: string }];
  'user:logout': [userId: string];
  'system:ready': [];
  'notification:show': [payload: { message: string; duration: number }];
}

Using a tuple with a single object element [payload: { ... }] is a common pattern that makes it easy to add more properties to the payload in the future without changing the listener's signature.

Under the Hood: Mapped Types

The patterns we've seen are enabled by a powerful TypeScript feature called Mapped Types. A mapped type lets you create a new type by iterating over the keys of an existing type and transforming them.

Let's consult the official TypeScript documentation for a formal definition.

Documentation - Mapped Types

Let's formalize our understanding by looking at the official documentation for Mapped Types. This will explain the fundamental syntax that makes our event map pattern work.

Read the introductory section of the documentation, down to (but not including) 'Mapping Modifiers'. Focus on the OptionsFlags<Type> example, which demonstrates the core [Property in keyof Type]: ... syntax.

Advanced Technique: Key Remapping

Mapped types are not limited to just transforming the values of properties; you can also transform the keys themselves using an as clause. This is an advanced feature that unlocks very expressive and powerful patterns. For instance, you could automatically generate event names like onNameChange from a data model's properties.

Documentation - Mapped Types

Now, let's explore a more advanced feature: remapping keys. This allows you to programmatically generate new property names within your mapped type, enabling powerful conventions.

Read the 'Key Remapping via as' section. The Getters<Type> example is particularly insightful, showing how to combine the as clause with template literal types to create new method names from property names.

This as clause allows for alternative design patterns. Instead of a manually defined map, you could define events as a discriminated union and use a mapped type to construct the final event map.

Here's an example of that pattern:

// 1. Define each event as a type in a union
type AppEvent =
  | { type: 'user:login'; payload: { id: string; name: string } }
  | { type: 'user:logout'; payload: { id: string } };

// 2. Use a mapped type with key remapping to build the listener map
type ListenerMap<Events extends { type: string }> = {
  [E in Events as E['type']]: (payload: E extends { payload: any } ? E['payload'] : undefined) => void;
};

// 3. The resulting type is what we need
type AppListeners = ListenerMap<AppEvent>;
/*
Resulting type:
type AppListeners = {
    'user:login': (payload: { id: string; name: string; }) => void;
    'user:logout': (payload: { id: string; }) => void;
}
*/

This approach is very robust and scales well, as you only need to add new types to the AppEvent union to expand your event system.

Conclusion

In this lesson, we've designed the cornerstone of our type-safe event bus. You have successfully defined what an event map is and explored multiple advanced TypeScript patterns for creating one.

Key Takeaways:

  • A type-safe event system relies on a contract that maps event names to their specific argument/payload types.
  • We can create this contract using a simple TypeScript interface where keys are event names and values are tuples of argument types.
  • This pattern is powered by Mapped Types combined with generics, the keyof operator, and indexed access types (Type[Key]).
  • Advanced techniques like key remapping with the as clause and using discriminated unions provide alternative, highly robust patterns for defining your event map.

In the next lesson, "Implement an EventBus class with a generic type parameter constrained by the event map", we will take the AppEventMap interface we designed today and use it to build the actual EventBus class, complete with type-safe on and emit methods.

Can't find a good explanation? Sign up and we'll make it for you

Sign up