We have been using typed events since Phase 2, but scattered across many posts. This post consolidates everything into a set of clear best practices, and adds a few refinements worth adopting before moving forward.
Practice 1: Enable Strict Mode, Fully
If your tsconfig.json does not already have this, add it now. This is the single most impactful setting for catching Socket.IO-related bugs early:
{ "compilerOptions": { "strict": true, "noUncheckedIndexedAccess": true } }
strict enables strictNullChecks, noImplicitAny, and several other checks together. noUncheckedIndexedAccess is not part of strict, but is worth adding separately, it makes accessing something like onlineUsers[0] return T | undefined instead of just T, which catches a very common class of bugs when working with arrays of connected users or messages.
Practice 2: Never Use any for Event Payloads
It can be tempting, especially under deadline pressure, to type a payload as any just to make an error go away. Avoid this entirely for Socket.IO events. The whole point of the typed event interfaces we built in Phase 2 is that a payload shape mismatch gets caught while you're writing the code, not after a user hits a broken feature in production. If you genuinely need an escape hatch for something external, unknown is safer than any, since it still forces you to narrow the type before using it.
Practice 3: Keep One Source of Truth for Event Types
We already followed this pattern from the start, a single src/types/socket.ts file, imported by both client and server. This is worth calling out explicitly as a rule, not just something we happened to do: never redeclare event interfaces separately on the client and server. If they drift apart, even slightly, TypeScript can no longer catch mismatches between what one side sends and what the other expects, defeating the entire purpose of typing this in the first place.
Practice 4: Use Discriminated Unions for Complex Payloads
Sometimes a single event can carry genuinely different kinds of data depending on context. Instead of making every field optional, which weakens type safety, use a discriminated union:
type NotificationPayload = | { type: "message"; text: string; sender: string } | { type: "friendRequest"; fromUserId: string } | { type: "systemAlert"; message: string; severity: "info" | "warning" };
export interface ServerToClientEvents { notification: (data: NotificationPayload) => void; }
On the receiving side, TypeScript can narrow the type automatically based on the type field:
socket.on("notification", (data) => { if (data.type === "message") { console.log(data.sender, data.text); } else if (data.type === "friendRequest") { console.log(data.fromUserId); } });
This is far safer than one event interface with five optional fields, where nothing tells you which fields actually belong together.
Practice 5: Type Acknowledgement Callbacks Properly
Recall the acknowledgement pattern from Topic 10. It is easy to leave the callback's response type loose. Type it explicitly instead:
export interface ClientToServerEvents { toggleFavorite: ( itemId: string, callback: (response: { success: boolean; error?: string }) => void ) => void; }
This ensures both the code that calls the callback on the server, and the code that receives its result on the client, agree exactly on what that response object looks like.
Practice 6: Type socket.data Precisely, and Extend It as Needed
We set up SocketData back in Phase 2 with a single userId field, and extended it further in the authentication post. As your app grows, keep this interface as the single place describing everything you attach to a connection over its lifetime:
export interface SocketData { userId: string; username: string; role: "user" | "admin"; }
Avoid attaching ad hoc properties to socket.data that are not declared here, doing so silently breaks the type safety this interface is meant to provide.
Practice 7: Remember These Types Are Compile-Time Only
This is worth repeating from Topic 8, since it matters even more now that our types have grown more advanced. Every practice in this post improves your experience while writing code, autocomplete, caught typos, enforced payload shapes. None of it validates data arriving at runtime from an actual client, especially one that might not even be running your TypeScript client code at all, for example, a malicious script talking directly to your WebSocket endpoint. The next post covers proper runtime validation and error handling, which is the actual safety net your types cannot provide on their own.
A Quick Before-and-After
Untyped, the kind of code you might write without any of this:
socket.on("sendMessage", (data: any) => { io.emit("newMessage", data); });
Typed, following the practices above:
socket.on("sendMessage", (data) => { io.emit("newMessage", { id: `${socket.id}-${Date.now()}`, text: data.text, sender: socket.data.username, timestamp: Date.now(), }); });
In the second version, data is already known to be { text: string; sender: string } from ClientToServerEvents, no any, no manual checking of whether data.text even exists, and your editor autocompletes every property.
Summary
- Enable
strictmode andnoUncheckedIndexedAccessintsconfig.json, this catches the most bugs for the least effort - Never type event payloads as
any; useunknownif you truly need an escape hatch - Keep event interfaces in one shared file, imported by both client and server, never redeclared separately
- Use discriminated unions for events that can carry genuinely different payload shapes
- Type acknowledgement callback responses explicitly, not just the initial event data
- Keep
SocketDataas the single, precise definition of everything attached to a connection - Compile-time types improve developer experience but do not replace runtime validation, covered next
In the next post, we cover error handling and validating data sent through sockets, the runtime safety net that complements everything we just built with TypeScript.
No comments:
Post a Comment