PHASE 5 — Topic 20: Error Handling and Validating Data Sent Through Sockets

We ended the last post with a warning: TypeScript types do not protect you at runtime. This post covers the actual safety net, validating incoming data properly, and handling errors without crashing your server.

Why This Matters More Than It Seems

Your ClientToServerEvents interface tells TypeScript what a sendMessage payload should look like. But that interface only exists at compile time, it gets erased completely once your code runs. A malicious or broken client can send absolutely anything over the wire, a missing field, a number where you expect a string, a message with 50,000 characters, and your server will happily try to process it unless you check.

Installing Zod

We'll use Zod, a widely used schema validation library that pairs naturally with TypeScript, since it can generate a matching type directly from a schema.


    npm install zod

Defining a Validation Schema

Create a new file, src/lib/validation.ts:


    import { z } from "zod";

    export const chatMessageSchema = z.object({
        text: z.string().trim().min(1).max(500),
        sender: z.string().trim().min(1).max(50),
    });

    export type ValidatedChatMessage = z.infer<typeof chatMessageSchema>;

    export const joinRoomSchema = z.string().trim().min(1).max(50);

  • .min(1) rejects empty strings, catching someone sending a blank message
  • .max(500) prevents extremely long payloads from being processed or broadcast
  • .trim() strips accidental leading/trailing whitespace before the length checks run
  • z.infer<typeof chatMessageSchema> gives you a TypeScript type automatically derived from the schema itself, so your validation rules and your types never drift apart

Validating Inside an Event Handler

Zod's .safeParse() never throws, it returns a result object you check manually, which fits naturally into Socket.IO's callback-style handlers:


    socket.on("chatMessage", (data) => {
        const result = chatMessageSchema.safeParse(data);

        if (!result.success) {
            socket.emit("errorMessage", "Invalid message format");
            return;
        }

        const validatedData = result.data;

        io.emit("newMessage", {
            id: `${socket.id}-${Date.now()}`,
            text: validatedData.text,
            sender: socket.data.username,
            timestamp: Date.now(),
        });
    });

Notice we use socket.data.username from our authenticated session, not validatedData.sender, tying back to the authentication post, the sender identity should always come from something you verified, not from data the client freely typed into the payload.

Add a New Event for Error Messages

Update src/types/socket.ts to include this new event we just used:


    export interface ServerToClientEvents {
        message: (data: { text: string; sender: string }) => void;
        hello: (text: string) => void;
        newMessage: (data: ChatMessage) => void;
        onlineCount: (count: number) => void;
        roomMessage: (data: ChatMessage) => void;
        errorMessage: (message: string) => void;
    }

And listen for it on the client:


    useEffect(() => {
        function onErrorMessage(message: string) {
            console.error("Server error:", message);
        }

        socket.on("errorMessage", onErrorMessage);

        return () => {
            socket.off("errorMessage", onErrorMessage);
        };
    }, []);

Validating Acknowledgement-Based Events

The same pattern applies when an event expects a response back, like toggleFavorite from Topic 10:


    socket.on("toggleFavorite", (itemId, callback) => {
        const result = z.string().min(1).safeParse(itemId);

        if (!result.success) {
            callback({ success: false, error: "Invalid item ID" });
            return;
        }

        callback({ success: true });
    });

Here, instead of emitting an error event, we pass the failure back through the acknowledgement callback itself, keeping the error tied directly to the specific request that failed.

Catching Unexpected Runtime Errors

Validation handles bad input. It does not handle unexpected failures inside your own logic, for example, a database call that throws. Wrap handler logic in a try/catch so one failing event does not crash your entire server process:


    socket.on("chatMessage", async (data) => {
        try {
            const result = chatMessageSchema.safeParse(data);

            if (!result.success) {
                socket.emit("errorMessage", "Invalid message format");
                return;
            }

            io.emit("newMessage", {
                id: `${socket.id}-${Date.now()}`,
                text: result.data.text,
                sender: socket.data.username,
                timestamp: Date.now(),
            });
        } catch (err) {
            console.error("Unexpected error in chatMessage handler:", err);
            socket.emit("errorMessage", "Something went wrong");
        }
    });

A single unhandled exception inside an event handler will not necessarily crash your entire Node.js process the way an uncaught error elsewhere might, but it can still leave things in an inconsistent state, or silently fail without telling the client anything went wrong. The try/catch guarantees the client always gets a response, success or failure, instead of the request silently disappearing.

Handling Connection-Level Errors

Beyond individual events, listen for connect_error on the client to catch failures happening before a connection is even established, such as our authentication middleware from Topic 18 rejecting a bad token:


    socket.on("connect_error", (err) => {
        console.error("Connection failed:", err.message);
    });

A General Rule Worth Adopting

Every event handler that receives data from a client should follow the same shape:

  1. Validate the incoming data with a schema
  2. Return early with an error response if validation fails
  3. Only then use the validated, typed data
  4. Wrap the core logic in try/catch for anything that could fail unexpectedly

Following this consistently across your entire server is what actually closes the gap that plain TypeScript types leave open.

Summary

  • TypeScript types are erased at runtime and cannot stop malformed or malicious data from reaching your handlers
  • Zod schemas validate incoming data at runtime, and can also generate matching TypeScript types automatically
  • .safeParse() returns a result object instead of throwing, fitting naturally into event handler logic
  • Sender identity should come from authenticated socket.data, never from client-supplied payload fields
  • Acknowledgement callbacks should return validation failures directly, keeping errors tied to the specific request
  • Wrapping handler logic in try/catch ensures a client always gets a response, even when something fails unexpectedly

This completes Phase 5. In the next post, we begin Phase 6 by looking at why Socket.IO needs special handling when you scale to multiple servers, before we introduce the Redis Adapter.

No comments:

Post a Comment

PHASE 5 — Topic 20: Error Handling and Validating Data Sent Through Sockets

We ended the last post with a warning: TypeScript types do not protect you at runtime. This post covers the actual safety net, validating in...