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 Emitters with Mapped Types

Hello! Welcome back to your journey of building a modern JavaScript framework.

In our last session, we laid the crucial groundwork for our type-safe EventBus. We designed its generic class structure, constrained it to a valid EventMap, and defined the internal listeners property using a mapped type. Our EventBus is currently a well-designed but empty shell.

Today, we bring it to life. Our learning goal is to implement the type-safe on and emit methods. By the end of this lesson, you will have a fully functional, type-safe event bus that enforces correct payload types for every event at compile time. This is where the architectural work we've done pays off in tangible developer experience and application robustness.

Let's start with the code we finished with last time:

// src/EventBus.ts

export class EventBus<
  // The EventMap must be an object with string keys and array values (the arguments).
  EventMap extends Record<string, any[]>
> {
  // A private property to store listeners for each event.
  private listeners: {
    [K in keyof EventMap]?: Set<(...args: EventMap[K]) => void>;
  } = {};

  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
  }
}

1. Implementing the Type-Safe on Method

The on method is responsible for registering a listener for a specific event. Its type signature, which we've already defined, is the key to its safety:

on<K extends keyof EventMap>(event: K, listener: (...args: EventMap[K]) => void): void

Let's break this down:

  • <K extends keyof EventMap>: This makes on a generic method. K is a type parameter that must be one of the keys from the EventMap. This ensures you can only subscribe to events that actually exist.
  • event: K: The first argument, the event name, is of type K.
  • listener: (...args: EventMap[K]) => void: The second argument is the listener function. Its parameters are typed using an indexed access type, EventMap[K]. This looks up the argument tuple associated with the event K in our map, ensuring the listener has the correct signature.

The implementation itself is straightforward: we need to get the Set of listeners for the given event, add our new listener to it, and update the listeners object.

The following resource provides a clear visual and code-based guide to implementing both on and emit.

TypeScript: Building a better EventEmitter

The video 'TypeScript: Building a better EventEmitter' by Tech Talks with Simon clearly demonstrates how to implement the on and emit methods. We will focus on the core logic.

Please watch the segment from 04:56 to 07:41. Focus on how the method signatures for on and emit are defined with generics (<K extends keyof EventMap>) and how the arguments are typed using the EventMap (EventMap[K]). This is the central mechanism for type safety.

Now, let's implement the on method. A clean way to do this is to use the nullish coalescing assignment operator (??=). This operator assigns the right-hand side value only if the left-hand side is null or undefined. It's a concise way to initialize the listener set if it's the first time we're adding a listener for a particular event.

// In EventBus.ts

on<K extends keyof EventMap>(event: K, listener: (...args: EventMap[K]) => void): void {
  // Use nullish coalescing assignment to initialize the Set if it doesn't exist.
  (this.listeners[event] ??= new Set()).add(listener);
}

This single line of code does the following:

  1. Accesses this.listeners[event].
  2. If it's undefined (i.e., no listeners for this event yet), it creates a new Set() and assigns it to this.listeners[event].
  3. Whether the Set was just created or already existed, it then calls .add(listener) on it.

2. Implementing the Type-Safe emit Method

Next is emit. Its job is to call all registered listeners for a given event with the provided arguments. Its signature mirrors on's for type safety:

emit<K extends keyof EventMap>(event: K, ...args: EventMap[K]): void

  • ...args: EventMap[K]: This uses a rest parameter to gather all arguments passed after the event name. Crucially, these arguments are validated against the EventMap[K] tuple, ensuring you can't emit an event with the wrong payload.

The implementation is even simpler:

  1. Retrieve the Set of listeners for the event.
  2. If the set exists, iterate over it and invoke each listener, spreading the args array into the function call.
// In EventBus.ts

emit<K extends keyof EventMap>(event: K, ...args: EventMap[K]): void {
  const listeners = this.listeners[event];

  if (listeners) {
    listeners.forEach(listener => listener(...args));
  }
}

With these two methods implemented, our EventBus is fully functional.

3. Putting It All Together: A Practical Demonstration

Let's see the type safety in action. First, we define an event map and instantiate our EventBus.

// example.ts
import { EventBus } from './EventBus';

// 1. Define the event map
interface AppEvents {
  'user:login': [user: { id: string; name: string }];
  'user:logout': []; // No payload
  'notification:send': [message: string, severity: 'info' | 'error'];
}

// 2. Instantiate the event bus with the map
const bus = new EventBus<AppEvents>();

// 3. CORRECT USAGE: TypeScript is happy
bus.on('user:login', (user) => {
  // 'user' is correctly typed as { id: string; name: string }
  console.log(`${user.name} logged in.`);
});

bus.on('notification:send', (message, severity) => {
  // 'message' is string, 'severity' is 'info' | 'error'
  console.log(`[${severity.toUpperCase()}]: ${message}`);
});

bus.emit('user:login', { id: '1', name: 'Alice' });
bus.emit('notification:send', 'Something went wrong!', 'error');
bus.emit('user:logout');

// 4. INCORRECT USAGE: TypeScript shows errors

// Error: Argument of type '"user:log_in"' is not assignable to parameter of type '"user:login" | "user:logout" | "notification:send"'.
bus.on('user:log_in', () => {});

// Error: Argument of type '[{ id: number }]' is not assignable to parameter of type '[user: { id: string; name: string; }]'.
// Type 'number' is not assignable to type 'string'.
bus.emit('user:login', { id: 123, name: 'Bob' }); 

// Error: Expected 2 arguments, but got 1.
bus.emit('notification:send', 'Hello');

This immediate feedback—autocompletion for event names and compile-time checks on payloads—is the direct result of our careful typing. The Stack Overflow answer below provides another concise example of this pattern.

How to implement Event bus types suggestion in Typescript

This Stack Overflow answer, 'How to implement Event bus types suggestion in Typescript', provides a compact and clear example of the pattern we've just implemented. It reinforces the core concepts with a slightly different implementation style (using an interface map and array for listeners).

Read the first part of the answer (down to the 'Bad calls' code block). Notice how it defines an EventArgs interface (our EventMap) and then uses generics in the addEventListener (our on) and emit methods to link event names to their argument types.

4. Revisiting "Key Remapping"

The learning outcome mentioned implementing these methods "using key remapping in mapped types." Let's clarify what this means in our context.

The power of our generic on and emit methods is enabled by the listeners object, which we defined using a mapped type:

{ [K in keyof EventMap]?: Set<(...args: EventMap[K]) => void>; }

This mapped type iterates through each key K in EventMap and creates a corresponding property on the listeners type. This is a direct 1:1 remapping of keys from the EventMap contract to the listeners internal state object.

TypeScript allows for even more powerful remapping. Using an as clause, you can transform keys. For instance, you could generate methods like onUserLogin and emitUserLogin directly. While not part of our current EventBus design, understanding this concept is valuable for any advanced TypeScript developer.

TypeScript Mapped Types as clauses // Hidden Gem

For a look at more advanced key remapping, this short video 'TypeScript Mapped Types as clauses // Hidden Gem' by basarat is excellent. It shows how to rename keys in a mapped type using template literal types and the as clause.

Watch the segment from 01:16 to 03:17. This is optional but highly recommended. It demonstrates how to transform property keys (e.g., from name to setName), a powerful technique that builds on the remapping concepts we've used.

5. An Alternative Architecture: The Discriminated Union Pattern

Our current implementation stores a separate Set of listeners for each event type. An elegant alternative, especially in an observable-based library like ours, is to use a single stream for all events and filter on the client side. This pattern uses a discriminated union.

Let's look at how to build this.

Building a Scalable, Type-Safe Event Bus - Grasp

The article 'Building a Scalable, Type-Safe Event Bus' from Grasp demonstrates this powerful alternative pattern. It's particularly relevant to us as it uses RxJS Subjects, which are conceptually similar to what we're building.

Please read 'Step 2: Create the Type-Safe Bus Service'. Pay close attention to these three key elements: The AppEvent discriminated union type. The trigger method, which wraps the event in an object { type, payload }. The listen method, which uses filter and map to isolate and extract the correct payload.

Let's summarize the discriminated union pattern:

  1. Create a Discriminated Union: You transform the EventMap into a union of all possible event objects.

    type AppEvent<EventMap> = {
      [K in keyof EventMap]: { type: K; payload: EventMap[K] };
    }[keyof EventMap];
    

    For our AppEvents, this would create a type like:
    { type: 'user:login', payload: [...] } | { type: 'user:logout', payload: [...] } | ...

  2. Use a Single Stream: The event bus manages a single stream (in our case, this will be a Subject) that emits AppEvent objects.
    private eventSubject = new Subject<AppEvent<EventMap>>();

  3. Implement emit (trigger): This method wraps the type and payload into an object and pushes it onto the stream.

    emit<K extends keyof EventMap>(type: K, ...payload: EventMap[K]): void {
      this.eventSubject.next({ type, payload } as AppEvent<EventMap>);
    }
    
  4. Implement on (listen): This method returns an observable that filters the main stream for a specific type and then maps the stream to just the payload. This logic will be implemented in the next lesson.

This architectural choice has different trade-offs (e.g., performance of one big stream vs. managing many listener sets), but it's a very powerful and common pattern in reactive programming.

Conclusion

Congratulations! You have now implemented fully functional and type-safe on and emit methods, completing the core logic of our EventBus.

Key Takeaways:

  • Generic methods (<K extends keyof EventMap>) are used on on and emit to constrain event names to those defined in the EventMap.
  • Indexed access types (EventMap[K]) are the key to ensuring listener functions and emitted payloads have the correct types for a given event.
  • The listeners object, defined with a mapped type, provides the internal structure that connects event names to their sets of listeners.
  • An alternative architecture using a single stream and a discriminated union offers a different, powerful approach to event management, especially in reactive systems.

Our EventBus is now a robust publisher-subscriber system. However, the goal of our library is to work with observables. In the next lesson, we will bridge this gap by implementing a method on the EventBus that returns a correctly typed observable for a specific event, making our EventBus a true source of reactive streams.

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

Sign up