The short answer
Describe the messages as a union keyed by type, parse each one, and let a type guard decide whether it is a message you know before using it:
/**
* @typedef {{ type: "stock", sku: string, quantity: number }
* | { type: "price", sku: string, cents: number }} ServerMessage
*/
/**
* @param {unknown} data
* @returns {data is ServerMessage}
*/
function isServerMessage(data) {
return typeof data === "object" && data !== null && "type" in data && (data.type === "stock" || data.type === "price");
}
const socket = new WebSocket("wss://shop.example/live");
socket.addEventListener("open", () => socket.send(JSON.stringify({ subscribe: ["LAMP-01"] })));
socket.addEventListener("message", (event) => {
if (typeof event.data !== "string") return;
const message = JSON.parse(event.data);
if (!isServerMessage(message)) return;
if (message.type === "stock") console.log(message.sku, message.quantity);
else console.log(message.sku, message.cents / 100);
});
const updates = new EventSource("/api/updates");
updates.addEventListener("message", (event) => {
const message = JSON.parse(event.data);
if (isServerMessage(message) && message.type === "price") console.log(message.cents);
});type ServerMessage = { type: "stock"; sku: string; quantity: number } | { type: "price"; sku: string; cents: number };
function isServerMessage(data: unknown): data is ServerMessage {
return typeof data === "object" && data !== null && "type" in data && (data.type === "stock" || data.type === "price");
}
const socket = new WebSocket("wss://shop.example/live");
socket.addEventListener("open", () => socket.send(JSON.stringify({ subscribe: ["LAMP-01"] })));
socket.addEventListener("message", (event) => {
if (typeof event.data !== "string") return;
const message = JSON.parse(event.data);
if (!isServerMessage(message)) return;
if (message.type === "stock") console.log(message.sku, message.quantity);
else console.log(message.sku, message.cents / 100);
});
const updates = new EventSource("/api/updates");
updates.addEventListener("message", (event) => {
const message = JSON.parse(event.data);
if (isServerMessage(message) && message.type === "price") console.log(message.cents);
});jsconfig.json for this example
{
"compilerOptions": {
"checkJs": true,
"noEmit": true,
"strict": true,
"target": "es2022",
"module": "nodenext",
"lib": ["es2022", "dom", "dom.iterable"],
"types": []
},
"include": ["*.js"]
} After isServerMessage(message), a check of message.type narrows the union, so message.quantity exists for stock updates and message.cents for prices.
Why it works
The guard turns untrusted data into a typed value in one place, and the union does the rest: it is a discriminated union, so each type value selects its own fields. The same guard serves the WebSocket and the EventSource, because both deliver the text of a message in event.data.
The same thing in TypeScript
The TypeScript equivalent tab declares type ServerMessage = … and function isServerMessage(data: unknown): data is ServerMessage. The event handling is identical.
What goes wrong
send() takes text or bytes, so an object literal is refused (TS2353); stringify it first. And a field that only some messages have cannot be read before narrowing (TS2339):
/** @typedef {{ type: "stock", sku: string, quantity: number } | { type: "price", sku: string, cents: number }} ServerMessage */
const socket = new WebSocket("wss://shop.example/live");
socket.send({ subscribe: ["LAMP-01"] });error TS2353: Object literal may only specify known properties, and 'subscribe' does not exist in type 'ArrayBufferLike | Blob | ArrayBufferView<ArrayBufferLike>'.
/** @param {ServerMessage} message */
function show(message) {
console.log(message.quantity);error TS2339: Property 'quantity' does not exist on type 'ServerMessage'.
Property 'quantity' does not exist on type '{ type: "price"; sku: string; cents: number; }'.
}
socket.addEventListener("message", (event) => show(JSON.parse(event.data)));jsconfig.json for this example
{
"compilerOptions": {
"checkJs": true,
"noEmit": true,
"strict": true,
"target": "es2022",
"module": "nodenext",
"lib": ["es2022", "dom", "dom.iterable"],
"types": []
},
"include": ["*.js"]
} The last line of that example passes although nothing is checked: JSON.parse(event.data) is any, which fits the ServerMessage parameter. That is why the first example parses into a variable and guards it.
Gotchas
- Named server-sent events need their own listener. An event sent with
event: pricearrives onaddEventListener("price", …), not on"message"; itsdatais stillany. - Binary messages are not strings. Set
socket.binaryType = "arraybuffer"to receiveArrayBuffers instead ofBlobs, and check the type ofevent.databefore parsing. - Reconnects are your code’s job for WebSocket.
EventSourcereconnects by itself; a closedWebSocketdoes not.
Questions
Why is event.data typed any?
The message event is a MessageEvent, whose data defaults to any: a socket can deliver text, a Blob or an ArrayBuffer, and the browser cannot know your protocol. Checking typeof event.data === "string" before parsing handles the binary case.
Is there a typed alternative to writing the guard by hand?
A schema library can check messages at runtime and give you the matching type. The hand-written guard shows what any such check has to do: confirm the shape before the code trusts it.
Sources
- WebSocket · MDN Web Docs
- EventSource · MDN Web Docs
Last reviewed 21 October 2026.