Typed RPC Contracts with Comlink
One exported type, imported by both threads, is the difference between a worker boundary the compiler checks and a pair of switch statements that drift apart over a release cycle.
This is the type-system half of Comlink & RPC Patterns, which itself sits under Web Workers Architecture & Communication. The runtime mechanics β proxy, transfer, release β are covered there; here the subject is the contract: what Remote<T> does to your API type, how to shape an API so the rewritten type stays usable, and the two classes of mistake the compiler still cannot see.
The Minimal Contract
Three files. The worker owns the implementation and exports its type; the page imports only the type.
// analysis-worker.ts β the implementation and the single source of truth
import * as Comlink from 'comlink';
export interface Range { min: number; max: number }
export class Analysis {
#series: Float64Array = new Float64Array(0);
load(series: Float64Array): void {
this.#series = series;
}
range(): Range {
let min = Infinity, max = -Infinity;
for (const v of this.#series) { if (v < min) min = v; if (v > max) max = v; }
return { min, max };
}
async movingAverage(window: number): Promise<Float64Array> {
const out = new Float64Array(this.#series.length);
let sum = 0;
for (let i = 0; i < this.#series.length; i++) {
sum += this.#series[i];
if (i >= window) sum -= this.#series[i - window];
out[i] = sum / Math.min(i + 1, window);
}
return out;
}
}
export type AnalysisApi = Analysis;
Comlink.expose(new Analysis());
// analysis-client.ts β the page. Note `import type`.
import * as Comlink from 'comlink';
import type { AnalysisApi } from './analysis-worker';
const worker = new Worker(new URL('./analysis-worker.ts', import.meta.url), { type: 'module' });
// Remote<AnalysisApi>: every member is now promise-returning.
export const analysis = Comlink.wrap<AnalysisApi>(worker);
export async function summarise(series: Float64Array) {
await analysis.load(Comlink.transfer(series, [series.buffer]));
const { min, max } = await analysis.range(); // one hop, plain object back
const smoothed = await analysis.movingAverage(24);
return { min, max, smoothed };
}
The whole contract is AnalysisApi. Rename movingAverage, change window to an options object, or add a required parameter, and the page fails to compile β in the same build, without a runtime test, and without anyone remembering that a string literal in a switch needed updating too.
Walking the Contract, Line by Line
export type AnalysisApi = Analysis. Exporting the class type rather than the instance keeps the contract honest for a class that is exposed as a live instance. If instead you expose a plain object literal, export type Api = typeof api captures its inferred shape β including method signatures β with no interface to maintain by hand.
import type, not import. This is the line that keeps a 900 kB parsing dependency out of the page bundle. A value import of the worker module would pull the entire implementation graph into the main chunk and still create the worker separately, doubling the download. With verbatimModuleSyntax: true in tsconfig.json, a plain import of something used only as a type becomes a compile error, which is the cheapest possible guard.
Comlink.wrap<AnalysisApi>(worker). The type argument is not decoration: without it the proxy is typed Remote<unknown> and every call silently becomes any. Wrap once, export the wrapped proxy from a module, and never call wrap at a call site β that keeps the type argument in exactly one place.
await analysis.load(...) even though load returns void. The await is not about the return value; it is about ordering and errors. Without it, a throw inside load becomes an unhandled rejection with no stack pointing at your code, and a subsequent range() may be posted before the failure is known.
Destructuring the result. const { min, max } = await analysis.range() costs one hop. Writing const min = await analysis.range().min does not compile β and its property-path equivalent, reading two remote properties separately, would cost two.
Object API or Class API?
Two shapes exist and they type differently, so pick deliberately.
An object API is exposed once and shared by every caller. export type Api = typeof api infers the contract from the implementation, which means it can never drift, and there is nothing to release because no proxy is ever created dynamically. It is the right default: one instance, one lifetime, no bookkeeping.
// object API β inferred contract, single shared instance
const api = {
async describe(csv: string) { /* β¦ */ },
async histogram(column: string, bins: number) { /* β¦ */ },
};
export type SheetApi = typeof api;
Comlink.expose(api);
A class API is exposed as the constructor itself, and the page creates instances across the boundary. That buys per-caller state β a session, a document, a loaded model β at the cost of a proxy you must release.
// class API β per-caller state, and a proxy per instance
export class Session { constructor(public readonly docId: string) {} /* β¦ */ }
Comlink.expose(Session);
// page: `new` on a remote constructor is itself an async round trip
const RemoteSession = Comlink.wrap<typeof Session>(worker);
const session = await new RemoteSession('doc-42'); // CONSTRUCT message
try { await session.recalculate(); }
finally { session[Comlink.releaseProxy](); }
Note the shape of that last line in the type system: Remote<typeof Session> makes new return Promise<Remote<Session>>, so the await sits outside the new. Getting this wrong (new (await RemoteSession)()) is the most common compile error people hit on their first class API.
A rule that scales: expose an object when the worker holds no per-caller state, and a class when it does. Mixing both on one worker is fine β expose a single object whose methods can also return constructed proxies.
Keeping One Copy of the Contract
The contract lives with the implementation, not in a separate types.ts that both sides import. That sounds backwards for a shared interface, and it is the point: a hand-maintained interface can disagree with the code that implements it, whereas typeof api cannot. TypeScript checks the implementation against callers directly.
There is one exception. If the payload types themselves are shared with the rest of the application β a Summary that a chart component also consumes β put those in a neutral module the worker imports too, and keep only the API surface inferred:
shared/types.ts Summary, Range, ColumnSpec β imported by page, worker, components
sheet-worker.ts the implementation + Comlink.expose
sheet-client.ts Comlink.wrap<SheetApi>, exported once
sheet-client.ts is the only file that mentions Comlink.wrap, which means the type argument exists in exactly one place, and swapping the worker for a pool or a synchronous fallback later touches one module instead of every component.
Verifying the Contract in a Test
A typed contract still deserves one runtime test per method, because the compile-time layer says nothing about cloneability or exposure timing. The cheapest useful test drives the real worker and asserts on shapes:
// analysis.contract.test.ts β runs in a browser-backed test runner
import { analysis } from './analysis-client';
it('answers every method with the declared shape', async () => {
await analysis.load(new Float64Array([1, 2, 3, 4]));
const range = await analysis.range();
expect(range).toEqual({ min: 1, max: 4 }); // proves the value clones
const smoothed = await analysis.movingAverage(2);
expect(smoothed).toBeInstanceOf(Float64Array); // proves the typed array survives
});
Under a jsdom-style environment Worker does not exist, so this test needs a real browser context; the runner options and the trade-offs are covered in Unit Testing Worker Modules with Vitest.
Gotchas the Compiler Cannot Catch
A type-correct call can still be a DataCloneError. TypeScript happily accepts analysis.load(document.body as unknown as Float64Array), and more realistically it accepts a perfectly typed class instance whose methods vanish in transit. Structured clone copies own enumerable data, not prototypes, so a Range interface survives the crossing while a Money class arrives without its format(). Keep the contractβs argument and return types restricted to plain data, typed arrays, Map, Set and Date β or register a transfer handler and keep the richer type.
Transfer detaches the argument, and the type still says it is a Float64Array. After analysis.load(Comlink.transfer(series, [series.buffer])), the local series has byteLength === 0 and the compiler is unaware. A convention helps more than a type: transfer only values the caller constructed for the call and never reads again, and never transfer something owned by application state.
Overloads and generics do not survive the rewrite intact. Remote<T> maps each member through a conditional type; a heavily overloaded method usually collapses to its last signature, and a generic method parameter can end up inferred as unknown at the call site. Prefer one signature with a discriminated-union parameter over three overloads.
Private fields are not part of the contract, but #-private state still lives in the worker. That is exactly what you want β the point of the boundary β but it means the page cannot read #series even though the class type mentions it. Expose an explicit accessor method if the page genuinely needs the value, and remember it costs a hop.
Performance Note
Typing costs nothing at runtime β Remote<T> is erased entirely β but it does change what feels cheap to write. A property read on a proxy compiles to a full round trip: 0.1β0.5 ms, versus roughly 2 ns for the local property access it resembles, a difference of five orders of magnitude. Design the contract so the natural way to use it is also the cheap way: return whole records instead of exposing fields, and prefer one method that answers a question over three that supply the ingredients for it.
A practical budget for interactive work: a user gesture should cost one round trip, occasionally two. If a component needs more, the contract is too fine-grained, and the fix belongs in the worker API rather than in the component.