Real-time Communication

TypeScript Real World Applications — Real-time Communication
TypeScript Real World Applications
Course 3 · Chapter 7 · Real-time Communication

⚡ Real-time Communication

Every real-time message crosses the same untyped boundary that request bodies (Chapter 3) and database rows (Chapter 6) do — the wire doesn't carry TypeScript types, only bytes. This chapter builds a typed message envelope for WebSockets, a type-safe event bus for pub/sub, and a light touch on event streaming at scale.

WebSocket Messages Arrive as Raw Strings

A raw ws message handler gives you a string (or a Buffer) — parsing it is on you, and nothing stops a malformed or unexpected message from crashing a handler:

socket.on("message", (raw) => { const data = JSON.parse(raw.toString()); // data: any if (data.type === "chat") { console.log(data.text.toUpperCase()); // ❌ crashes if "text" is missing or not a string } });

✉️ A Typed Message Envelope

The same discriminated-union pattern from Chapter 3's ApiResponse<T> works just as well for socket messages — a type field the compiler can narrow on:

type ClientMessage = | { type: "chat.send"; text: string; roomId: string; } | { type: "typing.start"; roomId: string; } | { type: "typing.stop"; roomId: string; }; type ServerMessage = | { type: "chat.received"; text: string; userId: string; timestamp: number; } | { type: "error"; message: string; }; function handleClientMessage(message: ClientMessage) { switch (message.type) { case "chat.send": console.log(`${message.roomId}: ${message.text}`); // ✅ narrowed break; case "typing.start": case "typing.stop": console.log(`Typing event in ${message.roomId}`); break; } }

Just like Chapter 3's request bodies, the envelope's type is not enough on its own — it still has to be validated at runtime before it's trusted (see the gotcha below).

Validating Messages at the Socket Boundary

A Type-Safe Message Router

import { z } from "zod"; const ClientMessageSchema = z.discriminatedUnion("type", [ z.object({ type: z.literal("chat.send"), text: z.string(), roomId: z.string() }), z.object({ type: z.literal("typing.start"), roomId: z.string() }), z.object({ type: z.literal("typing.stop"), roomId: z.string() }), ]); socket.on("message", (raw) => { const result = ClientMessageSchema.safeParse(JSON.parse(raw.toString())); if (!result.success) { send(socket, { type: "error", message: "Invalid message" }); return; } handleClientMessage(result.data); // fully typed AND validated });

📢 Type-Safe Pub/Sub

A plain EventEmitter lets you emit and listen for any string event with any payload — nothing stops a typo or a mismatched payload shape. A generic wrapper fixes that:

interface AppEvents { "order.placed": { orderId: string; total: number }; "order.cancelled": { orderId: string; reason: string }; "user.registered": { userId: string; email: string }; } class TypedEventBus<Events> { private listeners: { [K in keyof Events]?: Array<(payload: Events[K]) => void> } = {}; on<K extends keyof Events>(event: K, listener: (payload: Events[K]) => void) { (this.listeners[event] ??= []).push(listener); } emit<K extends keyof Events>(event: K, payload: Events[K]) { this.listeners[event]?.forEach((listener) => listener(payload)); } } const bus = new TypedEventBus<AppEvents>(); bus.on("order.placed", (payload) => { console.log(payload.orderId, payload.total); // ✅ payload typed exactly for this event }); // bus.emit("order.plcaed", { orderId: "1", total: 10 }); // ❌ "order.plcaed" not in AppEvents // bus.emit("order.placed", { orderId: "1" }); // ❌ missing required "total" bus.emit("order.placed", { orderId: "1", total: 49.99 }); // ✅

Event Map Interface

One interface lists every event name and its exact payload shape — the single source of truth for what can be emitted or listened for.

Generic on / emit

K extends keyof Events ties the event name to its payload type, so mismatches are compile errors, not silent no-ops.

Event Streaming at Scale

A TypedEventBus works well inside one process. Across services, tools like Kafka or Redis Streams take over — the typing principle stays identical, just applied to producers and consumers instead of emit/on:

interface OrderPlacedEvent { orderId: string; total: number; occurredAt: string; } async function publishOrderPlaced(producer: KafkaProducer, event: OrderPlacedEvent) { await producer.send({ topic: "orders.placed", messages: [{ value: JSON.stringify(event) }], }); } // Every consumer parses + validates incoming messages exactly like the // WebSocket handler above — the schema travels with the topic name.

💻 Coding Challenges

Challenge 1: Design a Message Envelope

Write a ClientMessage discriminated union for a simple game ("move", "chat", "leave"), plus a handleMessage function with a switch that narrows correctly on each variant.

Goal: Practice discriminated unions applied to real-time message shapes.

→ Solution

Challenge 2: Validate Incoming Socket Data

Write a validator (zod schema or hand-rolled type guard) for your ClientMessage union from Challenge 1, and a message handler that rejects anything that doesn't match with an "error" response.

Goal: Practice never trusting JSON.parse() output without validating it first.

→ Solution

Challenge 3: Build a Typed Event Bus

Implement TypedEventBus<Events> with generic on/emit methods, define an AppEvents interface with at least three events, and demonstrate that a mismatched payload fails to compile.

Goal: Practice using a generic class constrained by keyof to keep pub/sub fully type-safe.

→ Solution

⚠️ Gotcha: A Type Annotation Is Not a Runtime Guarantee

JSON.parse(raw) as ClientMessage compiles cleanly and proves nothing — a malicious or buggy client can send any JSON shape it wants, and as will not stop it. Every message boundary (WebSocket, Kafka consumer, any external event source) needs the same runtime validation Chapter 3 applied to HTTP request bodies. The type only describes what you expect; the validator confirms what you got.

🎯 What's Next

With messages flowing safely in real time, the next chapter turns to speed itself: Performance & Profiling — finding memory leaks, profiling CPU usage, and identifying bottlenecks in a running TypeScript application.