Recipe · Node and platforms

How do I type WebSocket and EventSource messages in JavaScript?

  • Browser (no bundler)
  • WebSocket
  • EventSource
  • Discriminated unions
  • Type guards
  • Checked with TypeScript 5.9

The problem My page receives stock and price updates over a WebSocket and an EventSource. event.data is any, so a wrong field name is never caught. How do I type the messages in JSDoc?

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:

live-stock.js live-stock.ts TypeScript 5.9.3 TypeScript 5.9.3, run 21 October 2026: No errors. 6.0.3 TypeScript 6.0.3, run 21 October 2026: the same as 5.9.3. No errors. 7.0.2 TypeScript 7.0.2, run 21 October 2026: the same as 5.9.3. No errors. checked 21 October 2026
/**
 * @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);
});
Messages parsed as unknown and narrowed by their type field.
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):

live-stock.js Error confirmed 5.9.3 TypeScript 5.9.3, run 21 October 2026: live-stock.js(5,15): error TS2353: Object literal may only specify known properties, and 'subscribe' does not exist in type 'ArrayBufferLike | Blob | ArrayBufferView<ArrayBufferLike>'. live-stock.js(10,23): error TS2339: Property 'quantity' does not exist on type 'ServerMessage'. Property 'quantity' does not exist on type '{ type: "price"; sku: string; cents: number; }'. 6.0.3 TypeScript 6.0.3, run 21 October 2026: the same as 5.9.3. live-stock.js(5,15): error TS2353: Object literal may only specify known properties, and 'subscribe' does not exist in type 'BufferSource | Blob'. live-stock.js(10,23): error TS2339: Property 'quantity' does not exist on type 'ServerMessage'. Property 'quantity' does not exist on type '{ type: "price"; sku: string; cents: number; }'. 7.0.2 TypeScript 7.0.2, run 21 October 2026: the same as 5.9.3. live-stock.js(5,15): error TS2353: Object literal may only specify known properties, and 'subscribe' does not exist in type 'Blob | BufferSource'. live-stock.js(10,23): error TS2339: Property 'quantity' does not exist on type 'ServerMessage'. Property 'quantity' does not exist on type '{ type: "price"; sku: string; cents: number; }'. checked 21 October 2026
/** @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)));
An object sent without stringifying, and a field read before narrowing.
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: price arrives on addEventListener("price", …), not on "message"; its data is still any.
  • Binary messages are not strings. Set socket.binaryType = "arraybuffer" to receive ArrayBuffers instead of Blobs, and check the type of event.data before parsing.
  • Reconnects are your code’s job for WebSocket. EventSource reconnects by itself; a closed WebSocket does 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

Last reviewed 21 October 2026.