Skip to content

RFC: Standard Schema validation and type inference for Socket.IO #5512

Open

Description

@GermanJablo

Summary

I would like to propose adding an optional contract property to the Socket.IO server and client constructors.

The goal is to let users define one shared event contract, pass it to Socket.IO, and get two things automatically:

  1. Runtime validation for sent and received payloads.
  2. TypeScript inference for the existing Socket.IO event generics.

The contract would use the Standard Schema interface, keeping validation library agnostic while preserving Socket.IO's existing API.

Motivation

Socket.IO already supports typed events with generics:

type ClientToServerEvents = {
  sendMessage: (
    req: { text: string },
    ack: (res: { ok: boolean }) => void,
  ) => void;
};

type ServerToClientEvents = {
  messageCreated: (req: { id: string; text: string }) => void;
};

const io = new Server<ClientToServerEvents, ServerToClientEvents>();

This is useful, but users still need to:

  • write the runtime schemas separately,
  • manually keep the schemas and TypeScript event types in sync,
  • manually pass the generics to Server and Socket,
  • validate payloads by hand in every handler or through their own wrapper.

The proposed API would make the contract the source of truth.

Proposed API

import { Server } from "socket.io";
import { io } from "socket.io-client";
import { z } from "zod";

const chatContract = defineSocketContract({
  errors: {
    UNAUTHORIZED: {
      data: z.object({ reason: z.string() }),
    },
  },

  clientToServer: {
    sendMessage: {
      req: z.object({ roomId: z.string(), text: z.string() }),
      res: z.object({ ok: z.boolean(), messageId: z.string() }),
      errors: {
        MESSAGE_TOO_LONG: {
          data: z.object({ maxLength: z.number() }),
        },
      },
    },

    typingStarted: {
      req: z.object({ roomId: z.string() }),
    },
  },

  serverToClient: {
    messageCreated: {
      req: z.object({
        id: z.string(),
        roomId: z.string(),
        text: z.string(),
      }),
    },
  },
});

// Server side
const server = new Server({
  contract: chatContract,
});

// Client side
const socket = io("https://example.com", {
  contract: chatContract,
});

The contract shape is intentionally close to Socket.IO's event model:

  • clientToServer contains events emitted by the client and received by the server.
  • serverToClient contains events emitted by the server and received by the client.
  • req is the event payload.
  • res is the acknowledgement payload, when the event uses an acknowledgement.
  • errors can be defined globally or per event.

The schemas should use the Standard Schema interface, so users can choose Zod, Valibot, ArkType, Effect Schema, or any other compatible validation library.

Type inference

When a contract is provided, Socket.IO could infer the same types that users currently pass manually as generics.

For the server:

const server = new Server({
  contract: chatContract,
});

This would be equivalent to:

type ClientToServerEvents = InferClientToServerEvents<typeof chatContract>;
type ServerToClientEvents = InferServerToClientEvents<typeof chatContract>;

const server = new Server<ClientToServerEvents, ServerToClientEvents>();

For the client:

const socket = io("https://example.com", {
  contract: chatContract,
});

This would infer the opposite direction automatically, as Socket.IO client types do today:

type ServerToClientEvents = InferServerToClientEvents<typeof chatContract>;
type ClientToServerEvents = InferClientToServerEvents<typeof chatContract>;

const socket: Socket<ServerToClientEvents, ClientToServerEvents> = io(url);

So users would not need a separate utility just to derive and pass these generic parameters.

Runtime validation

If a contract is provided, Socket.IO would validate payloads before they are delivered to user code.

For example, if the client emits this event:

socket.emit("sendMessage", {
  roomId: "room_1",
  text: "hello",
});

Socket.IO would validate the payload against:

clientToServer.sendMessage.req

If the payload is valid, the server handler receives the typed value:

server.on("connection", (socket) => {
  socket.on("sendMessage", async (req, ack) => {
    // req is inferred as:
    // { roomId: string; text: string }

    ack([undefined, { ok: true, messageId: "msg_123" }]);
  });
});

If the payload is invalid, Socket.IO would not call the handler. For acknowledged events, it could call the acknowledgement callback with REQUEST_VALIDATION_ERROR.

Socket.IO would validate:

  • incoming payloads before calling the receiver,
  • outgoing payloads before sending, unless disabled by the contract,
  • successful acknowledgement payloads before delivering them to the caller.

The normal Socket.IO API would stay the same:

socket.emit("messageCreated", {
  id: "msg_123",
  roomId: "room_1",
  text: "hello",
});

The payload would be checked against:

serverToClient.messageCreated.req

Error model

Acknowledgements could use a typed result tuple:

type Result<Data, Error> =
  | [error: undefined, data: Data]
  | [error: Error, data: undefined];

For example:

socket.emit("sendMessage", { roomId: "room_1", text: "hello" }, (result) => {
  const [error, data] = result;

  if (error) {
    switch (error.code) {
      case "MESSAGE_TOO_LONG":
        error.data.maxLength;
        return;

      case "REQUEST_VALIDATION_ERROR":
        error.issues;
        return;

      case "RESPONSE_VALIDATION_ERROR":
      case "UNKNOWN_ERROR":
        return;
    }
  }

  data.messageId;
});

This would not be a breaking change for existing Socket.IO users because the tuple result would only apply when a contract is passed. Passing a contract would opt into a typed acknowledgement result format.

Default errors

Socket.IO could handle three default error codes out of the box:

type DefaultSocketContractError =
  | {
      code: "REQUEST_VALIDATION_ERROR";
      issues: StandardSchemaIssue[];
    }
  | {
      code: "RESPONSE_VALIDATION_ERROR";
    }
  | {
      code: "UNKNOWN_ERROR";
      message: string;
    };

These cover the common cases:

  • REQUEST_VALIDATION_ERROR: the received event payload did not match the contract.
  • RESPONSE_VALIDATION_ERROR: the acknowledgement response did not match the contract.
  • UNKNOWN_ERROR: the handler threw an unknown error.

Custom errors

Users could add shared errors at the contract level:

const contract = defineSocketContract({
  errors: {
    UNAUTHORIZED: {
      data: z.object({ reason: z.string() }),
    },
  },

  clientToServer: {
    sendMessage: {
      req: z.object({ text: z.string() }),
      res: z.object({ ok: z.boolean() }),
    },
  },

  serverToClient: {},
});

They could also add event-specific errors:

const contract = defineSocketContract({
  clientToServer: {
    sendMessage: {
      req: z.object({ text: z.string() }),
      res: z.object({ ok: z.boolean() }),
      errors: {
        MESSAGE_TOO_LONG: {
          data: z.object({ maxLength: z.number() }),
        },
      },
    },
  },

  serverToClient: {},
});

These errors would be included in the acknowledgement result type for that event.

Why this should live in Socket.IO

This feature can be built as an external helper, but an external helper has limits:

  • it usually needs to wrap part of the Socket.IO API,
  • it can be hard to keep full compatibility with rooms, namespaces, modifiers, middleware, and acknowledgements,
  • users still need extra imports and helper types,
  • type inference cannot be as natural as constructor-level inference.

If Socket.IO accepts a contract directly, users can keep using the existing Socket.IO API while getting runtime validation and inferred types from the same source of truth.

Backward compatibility

This would be fully optional.

Existing users could continue using Socket.IO exactly as they do today:

const server = new Server<ClientToServerEvents, ServerToClientEvents>();

The new behavior would only apply when a contract property is passed:

const server = new Server({
  contract,
});

The typed acknowledgement tuple would also only apply in this contract mode.

Outgoing validation

A common validation rule is: always validate what you receive, and optionally validate what you send depending on cost and context.

With that in mind, incoming validation should probably be enabled when a contract is provided. Outgoing validation could be opt-out in the contract, either globally or per event:

const contract = defineSocketContract({
  validateOutgoing: true,

  clientToServer: {
    highVolumeEvent: {
      validateOutgoing: false,
      req: schema,
    },
  },

  serverToClient: {},
});

The model would only need two validation directions:

  • incoming: events received by this side
  • outgoing: events sent by this side

Closing

I am willing to help with the implementation, but I would like to hear opinions and get feedback first because this is a large feature.

Activity

  1. darrachequesne commented on Jun 16, 2026

    @darrachequesne
    Member

    Thanks for the suggestion!

    I agree that this is an interesting feature. Having first-class support for runtime validation combined with TypeScript inference could improve the developer experience, especially for applications with many events and complex payloads.

    That being said, I think that this can already be implemented today in userland with a small helper, for example:

    import { z } from "zod";
    import { type Socket } from "socket.io";
    
    type Ack =
      | { ok: true }
      | { ok: false; error: string };
    
    function onValidated<T>(
      socket: Socket,
      eventName: string,
      schema: z.ZodSchema<T>,
      handler: (
        data: T,
        callback?: (response: Ack) => void
      ) => void | Promise<void>
    ) {
      socket.on(
        eventName,
        async (payload: unknown, callback?: (response: Ack) => void) => {
          const result = schema.safeParse(payload);
    
          if (!result.success) {
            callback?.({ ok: false, error: "Invalid payload" });
            return;
          }
    
          await handler(result.data, callback);
        }
      );
    }

    Usage:

    const JoinRoomSchema = z.object({
      roomId: z.string().min(1).max(100),
    });
    
    io.on("connection", (socket) => {
      onValidated(socket, "room:join", JoinRoomSchema, async (data, callback) => {
        await socket.join(data.roomId);
        callback?.({ ok: true });
      });
    });

    And on the client side:

    type JoinRoomPayload = z.infer<typeof JoinRoomSchema>;
    
    interface ClientToServerEvents {
      "room:join": (
        payload: JoinRoomPayload,
        callback: (response: Ack) => void
      ) => void;
    }

    This already provides:

    • runtime validation of incoming payloads
    • inferred TypeScript types from the schema
    • centralized error handling
    • no changes required in Socket.IO itself

    So while native support could reduce boilerplate and maybe standardize the pattern, I’m not sure it is strictly necessary in the core library. It might be a good candidate for documentation, an example, or a small companion utility package instead.

    What do you think?

  2. GermanJablo commented on Jun 18, 2026

    @GermanJablo
    Author

    Thanks for the thoughtful reply!

    I agree that this can be partially implemented in userland. The helper you shared works for one narrow case: validating an incoming server-side payload before a handler runs.

    But I think the main problem this proposal is trying to solve is broader: keeping the runtime validator, event name, event direction, TypeScript event map, acknowledgement response, and error shape in sync across both sides of the connection.

    In your example:

    • nothing prevents the user from defining JoinRoomSchema and then writing a ClientToServerEvents interface that does not match it
    • nothing connects the string "room:join" passed to onValidated(...) with the "room:join" key in ClientToServerEvents
    • nothing validates outgoing payloads before socket.emit(...)
    • nothing validates server-to-client events
    • nothing validates successful acknowledgement responses against a response schema
    • nothing gives a typed per-event error model beyond a generic { ok: false; error: string }
    • nothing infers the Socket.IO generics from the contract itself

    Some of these problems could be solved by making the helper larger and more sophisticated. But it is a very large and error-prone responsibility delegated to users.

    I actually started from the same assumption: that this could live as a small package on top of Socket.IO. After spending the week trying to build one, the hard part was not validation itself, but the repeated glue needed around every handler, emit, acknowledgement, error, and event map. That is what made me think this needs to be baked into the Socket.IO boundary instead of layered on top.

    Having an official pattern will also strengthen the ecosystem, as it will prevent all users from solving this problem differently.

    Socket.IO currently documents explicit generics for payloads that cross the client/server boundary (Socket<ServerToClientEvents, ClientToServerEvents>). Those generics provide compile-time confidence, but no runtime guarantee. For boundary data, that can become misleading if the types are not backed by validation.

    This is why libraries like tRPC, oRPC, TanStack (Server Functions) infer the types from the validators and force you to define them.

    TLDR: this should be native because it:

    • makes the contract the single source of truth
    • keeps validation, event types, acknowledgement types, and error types together
    • avoids repeated glue code around every on and emit
    • lets Socket.IO generics be inferred from the contract
    • prevents the protocol from drifting across separate userland helpers.
  3. darrachequesne commented on Oct 2, 2026

    @darrachequesne
    Member

    Upon further review, I think this could indeed be a great addition to the library 👍

    Related: #3822

    Note: using a custom parser (https://socket.io/docs/v4/custom-parser/) is another possible solution, but it would only cover part of the problem. A parser can validate decoded packets at a low level, but I think that the handling of invalid payloads and acknowledgements needs to be done at the server/client level.

    A few questions regarding your proposal:

    1. Event arguments

    The proposal uses:

    sendMessage: {
      req: z.object({ roomId: z.string(), text: z.string() })
    }

    But Socket.IO events can have multiple arguments:

    socket.emit("event", "a", 123, { ok: true });

    Should the contract feature support multiple event arguments?

    1. Validation errors

    The proposal changes the shape of acknowledgements in contract mode:

    ack([undefined, { ok: true, messageId: "msg_123" }]);

    How about Node-style (err, data) callbacks instead?

    socket.emit("sendMessage", {
      roomId: "room_1",
      text: "hello",
    }, (err, data) => {
      // ...
    });
    
    socket.on("sendMessage", async (req, ack) => {
      ack(null, { ok: true, messageId: "msg_123" });
    });

    I think this might work well with the Promise-based API too:

    try {
      const { messageId } = await socket.timeout(5_000).emitWithAck("sendMessage", {
        roomId: "room_1",
        text: "hello",
      });
    } catch (err) {
      // current behavior: timeout / disconnection
      // in contract mode: invalid request or response payload
    }
    1. Async validation

    Some Standard Schema implementations support async validation. Should we support this? In that case, what about the delivery order?

    1. Namespaces

    Should the contract apply to all namespaces, only the default namespace, or should each namespace be able to define its own contract?

  4. GermanJablo commented on Oct 8, 2026

    @GermanJablo
    Author

    Thank you very much, @darrachequesne! I'm so excited that you find this proposal valuable!

    And from the quality of your questions, I can tell that you put a lot of effort into reviewing this. 🫶

    1. Event arguments

    I know Socket.IO supports multiple arguments, and both approaches are viable, though it's partly a matter of personal preference. Personally, I would prefer a single object:

    • It lets us define the message directly as req or res, without wrapping the schema in a tuple like args: z.tuple([z.object({...})]).
    • Positional arguments of the same type can be swapped without TypeScript catching the mistake: socket.emit("sendMessage", text, roomId) could be valid even if the signature expects the opposite order.
    • An object makes it easier to add options and avoids calls like socket.emit("sendMessage", roomId, text, undefined, replyTo).

    The downside is that existing multi-argument events must be converted into an object before they can use a contract. Since contracts are opt-in, I think that is acceptable.

    2.a. Validation errors (tuple)

    I don’t have strong preferences for the callback API; both options work for me. However, wouldn’t it be better for the Promise-based API to return the tuple instead of rejecting the promise? That would preserve the types of event-specific errors and make it harder to forget to handle them before using the data. This behavior would only apply when the user provides a contract.

    2.b. Timeouts and disconnections

    I'm glad you brought up timeouts, since I omitted them from my initial proposal. I'd add two default errors:

    • TIMEOUT_ERROR: included in the inferred error type only when a timeout is configured, either with .timeout(...) or with the client's ackTimeout option.
    • DISCONNECTED_ERROR: always included in the client’s inferred error type, since emitWithAck can fail when the socket disconnects while waiting for an acknowledgement, even without a timeout.

    The typing could follow the same pattern Socket.IO already uses: .timeout() returns a socket whose acknowledgement types are decorated with DecorateAcknowledgements<EmitEvents>. In contract mode, that decoration would add TIMEOUT_ERROR to the error union:

    type ClientErrors<HasTimeout extends boolean> =
      | RequestValidationError
      | ResponseValidationError
      | DisconnectedError
      | UnknownError
      | (HasTimeout extends true ? TimeoutError : never);

    For example:

    // No timeout: TIMEOUT_ERROR is not part of the error type
    const result = await socket.emitWithAck("sendMessage", req);
    
    // Per-emit timeout: TIMEOUT_ERROR is included
    const timedResult = await socket
      .timeout(5_000)
      .emitWithAck("sendMessage", req);
    
    // Default timeout from the client options: TIMEOUT_ERROR is included
    const socketWithTimeout = io(url, { contract, ackTimeout: 5_000 });

    These default errors would be combined with the contract-level and event-specific errors.

    3. Async validation

    Good question! Ideally, I think we should validate in parallel but deliver events in order. However, I propose initially supporting only synchronous validation and leaving async validation for a follow-up PR. In the meantime, if a validator returns a promise, we should explicitly report that it is not supported yet.

    4. Namespaces

    Applying the same contract to all namespaces would force them to share event definitions, while supporting contracts only in the default namespace would be limiting. Allowing each namespace to define its own contract seems like the right call.

    I propose this API:

    const server = new Server({
      contract: defaultContract, // Only "/"
    });
    
    const chat = server.of("/chat", { contract: chatContract });
    const admin = server.of("/admin", { contract: adminContract });

    I initially considered allowing contract to accept a namespace-to-contract map:

    const server = new Server({
      contract: {
        "/": defaultContract,
        "/chat": chatContract,
        "/admin": adminContract,
      },
    });

    But object keys wouldn’t support function matchers. Passing the contract to .of() seems more consistent with the existing API, and each namespace already has a separate socket on the client.


    I hope this makes sense! Please let me know what you think!

  5. safal207 commented on Oct 10, 2026

    @safal207
  6. GermanJablo commented on Oct 11, 2026

    @GermanJablo
    Author

    The comment above is AI slop suggesting tests for something obvious; I suggest marking it as spam. Please don't waste our time with things you don't understand.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or request

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions