38.3 Web Worker Typed Message Passing
Alright, let’s talk about passing messages to your Web Workers. This is where you, the developer, are most likely to screw up. The browser’s main thread and your worker thread are like two separate islands, shouting messages to each other across a vast ocean. They don’t share memory, which is both Web Workers’ greatest strength (no locking, no race conditions!) and their most annoying constraint.
You can’t just hand them a reference to a function or a complex DOM node. The postMessage() API uses the structured clone algorithm to serialize and deserialize whatever you send. It’s surprisingly capable—it handles Maps, Sets, ArrayBuffers, and even circular references! But it has limits. Try sending a function or a DOM element and you’ll get a DataCloneError slapped in your face. Rightly so.
The problem? This system is dynamically typed. You postMessage({ type: 'calculate', payload: 42 }) and on the other side, you have to guess what you got. Was it payload or data? Is 42 a number or a string that happens to be a number? This is a recipe for runtime bugs. And that, my friend, is where TypeScript waltzes in to save the day. We’re going to enforce this communication with a contract.
Defining the Message Contract with Discriminated Unions
The single most effective pattern for typed worker messages is the discriminated union. We’ll define all possible messages that can be sent to the worker (and often, back from it) as a union of types, discriminated by a type field. This allows TypeScript to perform flawless type narrowing.
// types.ts
// Messages the MAIN thread can send to the WORKER
export type ToWorkerMessage =
| {
type: "CALCULATE_SUM";
payload: number[];
}
| {
type: "FETCH_DATA";
payload: { url: string; retries: number };
}
| {
type: "SHUTDOWN";
};
// Messages the WORKER can send back to the MAIN thread
export type FromWorkerMessage =
| {
type: "RESULT";
payload: number;
}
| {
type: "ERROR";
payload: string;
}
| {
type: "PROGRESS";
payload: number;
};
This is our contract. Any message not matching these shapes is a lie and shall be treated as such.
Implementing the Typed Worker
Now, let’s use these types to build a worker that’s a joy to work with, not a guessing game.
In your main thread code:
// main.ts
import { ToWorkerMessage, FromWorkerMessage } from "./types";
const worker = new Worker(new URL("./worker.ts", import.meta.url), {
type: "module", // Crucial for using import statements in the worker
});
// Sending a message is now fully typed.
const message: ToWorkerMessage = {
type: "CALCULATE_SUM",
payload: [1, 2, 3, 4],
};
worker.postMessage(message);
// Receiving a message is also fully typed.
worker.onmessage = (event: MessageEvent<FromWorkerMessage>) => {
// TypeScript now knows the shape of `event.data` based on its `type`!
switch (event.data.type) {
case "RESULT":
console.log(`Result: ${event.data.payload}`);
break;
case "ERROR":
console.error(`Worker exploded: ${event.data.payload}`);
break;
case "PROGRESS":
updateProgressBar(event.data.payload); // payload is a number here
break;
}
};
Inside your Web Worker (worker.ts):
// worker.ts
import { ToWorkerMessage, FromWorkerMessage } from "./types";
// Type the incoming event! This is the key.
self.onmessage = (event: MessageEvent<ToWorkerMessage>) => {
const message = event.data; // TypeScript now knows this is a ToWorkerMessage
switch (message.type) {
case "CALCULATE_SUM":
// TypeScript knows `message.payload` is a number[] here.
const sum = message.payload.reduce((a, b) => a + b, 0);
const resultMessage: FromWorkerMessage = {
type: "RESULT",
payload: sum,
};
self.postMessage(resultMessage);
break;
case "FETCH_DATA":
// message.payload is { url: string; retries: number } here
fetchData(message.payload.url, message.payload.retries)
.then((data) => {
/*...*/
})
.catch((error) => {
/*...*/
});
break;
case "SHUTDOWN":
self.close(); // TypeScript knows there's no payload to check.
break;
}
};
Handling the Inevitable: Bad Data and Graceful Degradation
Here’s the brutal truth: TypeScript types are a development-time safety net. They evaporate at runtime. Someone might postMessage("hello world") directly in the console, or a mangled message might come from a different origin. Your worker must be a paranoid fortress.
// A robust, paranoid message handler inside the worker
self.onmessage = (event: MessageEvent<unknown>) => {
// First, validate the incoming data. Use a type guard.
if (isValidToWorkerMessage(event.data)) {
// Now we can safely use the discriminated union pattern
processMessage(event.data);
} else {
// Send a typed error back instead of crashing silently.
const errorMessage: FromWorkerMessage = {
type: "ERROR",
payload: `Received invalid message: ${JSON.stringify(event.data)}`,
};
self.postMessage(errorMessage);
}
};
// A simple type guard using a library like Zod or just a quick check
function isValidToWorkerMessage(data: unknown): data is ToWorkerMessage {
return (
typeof data === "object" &&
data !== null &&
"type" in data &&
typeof data.type === "string"
);
// For production, you'd want a more robust validation (e.g., with Zod)
}
This pattern transforms your worker communication from a fragile, stringly-typed mess into a robust, self-documenting, and refactorable API. You get autocomplete, you get error checking, and you get to feel like a competent professional instead of a script kiddie tossing any around. And that, frankly, is the goal.