Repository navigation
RFC: Standard Schema validation and type inference for Socket.IO #5512
Description
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:
- Runtime validation for sent and received payloads.
- 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
ServerandSocket, - 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:
clientToServercontains events emitted by the client and received by the server.serverToClientcontains events emitted by the server and received by the client.reqis the event payload.resis the acknowledgement payload, when the event uses an acknowledgement.errorscan 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.reqIf 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.reqError 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
darrachequesne commentedon Jun 16, 2026
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:
Usage:
And on the client side:
This already provides:
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?
GermanJablo commentedon Jun 18, 2026
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:
JoinRoomSchemaand then writing aClientToServerEventsinterface that does not match it"room:join"passed toonValidated(...)with the"room:join"key inClientToServerEventssocket.emit(...){ ok: false; error: string }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:
onandemitdarrachequesne commentedon Oct 2, 2026
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:
The proposal uses:
But Socket.IO events can have multiple arguments:
Should the contract feature support multiple event arguments?
The proposal changes the shape of acknowledgements in contract mode:
How about Node-style
(err, data)callbacks instead?I think this might work well with the Promise-based API too:
Some Standard Schema implementations support async validation. Should we support this? In that case, what about the delivery order?
Should the contract apply to all namespaces, only the default namespace, or should each namespace be able to define its own contract?
GermanJablo commentedon Oct 8, 2026
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:
reqorres, without wrapping the schema in a tuple likeargs: z.tuple([z.object({...})]).socket.emit("sendMessage", text, roomId)could be valid even if the signature expects the opposite order.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'sackTimeoutoption.DISCONNECTED_ERROR: always included in the client’s inferred error type, sinceemitWithAckcan 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 withDecorateAcknowledgements<EmitEvents>. In contract mode, that decoration would addTIMEOUT_ERRORto the error union:For example:
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:
I initially considered allowing
contractto accept a namespace-to-contract map: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!
safal207 commentedon Oct 10, 2026
GermanJablo commentedon Oct 11, 2026
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.