SOLUTION: Challenge 3 - Observer with Type-Safe Events ====================================================== Challenge: Implement an event bus with typed events (discriminated union). Add/remove listeners for specific events. Emit with type safety. --- SOLUTION: // Discriminated union for events type AppEvent = | { type: "user.created"; userId: number; email: string } | { type: "user.deleted"; userId: number } | { type: "post.published"; postId: number; title: string } | { type: "error"; message: string; code: string }; // Extract payload for a given event type type EventPayload = Extract; // Listener type (callback for an event) type EventListener = (event: EventPayload) => void; // Event bus class class EventBus { private listeners: Map> = new Map(); // Subscribe to an event type on(type: T, listener: EventListener): void { if (!this.listeners.has(type)) { this.listeners.set(type, new Set()); } this.listeners.get(type)!.add(listener); console.log(`šŸ‘‚ Listener added for event: ${type}`); } // Unsubscribe from an event type off(type: T, listener: EventListener): void { const listeners = this.listeners.get(type); if (listeners) { listeners.delete(listener); console.log(`šŸ”‡ Listener removed for event: ${type}`); } } // Emit an event (type-safe!) emit( type: T, event: Omit, "type"> ): void { console.log(`šŸ“¢ Emitting event: ${type}`, event); const listeners = this.listeners.get(type); if (listeners) { listeners.forEach(listener => { listener({ type, ...event } as EventPayload); }); } } // Get listener count for debugging getListenerCount(type: AppEvent["type"]): number { return this.listeners.get(type)?.size ?? 0; } } --- // USAGE: Type-safe event handling const bus = new EventBus(); // Listener for user.created event bus.on("user.created", (event) => { console.log( `āœ… New user created: ${event.email} (ID: ${event.userId})` ); // event.userId āœ… available // event.email āœ… available // event.postId āŒ doesn't exist (type error if you try) }); // Listener for user.deleted event bus.on("user.deleted", (event) => { console.log(`šŸ—‘ļø User deleted: ID ${event.userId}`); }); // Listener for post.published event bus.on("post.published", (event) => { console.log(`šŸ“ Post published: "${event.title}" (ID: ${event.postId})`); }); // Listener for error event bus.on("error", (event) => { console.error(`āŒ Error [${event.code}]: ${event.message}`); }); --- // EMIT EVENTS console.log("\n=== Emitting Events ===\n"); // Emit user.created (type-safe: must include userId and email) bus.emit("user.created", { userId: 1, email: "alice@example.com" // Missing fields: TypeScript error! }); // Emit user.deleted bus.emit("user.deleted", { userId: 1 }); // Emit post.published bus.emit("post.published", { postId: 101, title: "My First Post" }); // Emit error bus.emit("error", { code: "DB_CONNECTION_FAILED", message: "Could not connect to database" }); console.log(`\nšŸ“Š Listener counts:`); console.log(` user.created: ${bus.getListenerCount("user.created")}`); console.log(` user.deleted: ${bus.getListenerCount("user.deleted")}`); console.log(` post.published: ${bus.getListenerCount("post.published")}`); console.log(` error: ${bus.getListenerCount("error")}`); --- ADVANCED: Unsubscribe // Create a listener function const handleUserCreated: EventListener<"user.created"> = (event) => { console.log(`Welcome, ${event.email}!`); }; // Subscribe bus.on("user.created", handleUserCreated); // Emit (listener fires) bus.emit("user.created", { userId: 2, email: "bob@example.com" }); // Unsubscribe bus.off("user.created", handleUserCreated); // Emit again (listener doesn't fire) bus.emit("user.created", { userId: 3, email: "charlie@example.com" }); --- ADVANCED: Async Listeners // Async event listener async function handleUserCreatedAsync(event: EventPayload<"user.created">) { console.log(`Processing async for ${event.email}`); await new Promise(resolve => setTimeout(resolve, 100)); console.log(`Finished processing ${event.email}`); } bus.on("user.created", handleUserCreatedAsync); bus.emit("user.created", { userId: 4, email: "dave@example.com" }); --- EXPLANATION: DISCRIMINATED UNION: type AppEvent = | { type: "user.created"; userId: number; email: string } | { type: "user.deleted"; userId: number } ... Each event variant has: - A unique `type` field (discriminator) - Event-specific properties TYPE EXTRACTION: type EventPayload = Extract; Extract → { type: "user.created"; userId: number; email: string } Extract → { type: "user.deleted"; userId: number } This gives us the exact properties for each event type. TYPE-SAFE LISTENERS: type EventListener = (event: EventPayload) => void; bus.on("user.created", (event) => { // event is typed as EventPayload<"user.created"> // event.userId āœ… // event.email āœ… // event.code āŒ Error: doesn't exist on this event type }); BENEFITS: 1. No stringly-typed events: type system catches typos 2. No payload confusion: can't access wrong event's properties 3. Exhaustiveness: TypeScript warns if you forget to handle an event 4. Refactoring: rename event or property → errors everywhere 5. Autocomplete: IDE knows all available events and properties --- REAL-WORLD USAGE: This pattern is used in: - React Context + useReducer (Redux-like state management) - Node.js EventEmitter (with types added) - Browser CustomEvent (with type safety layer) - Message queues (RabbitMQ, Kafka) - WebSocket protocols (type-safe message format) Observer + discriminated unions is the most type-safe way to implement event-driven architecture in TypeScript.