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.

How Remote rewrites each member of the worker API type A two-column mapping. On the left, the source API type has a void-returning load method, a range method returning a Range object, a movingAverage method already returning a promise of Float64Array, and a readonly length number property. On the right, the Remote version of the same type turns load into a promise of void, range into a promise of Range, movingAverage into a promise of Float64Array without nesting, and the length property into a promise of number, because reading it is itself a round trip. AnalysisApi becomes Remote<AnalysisApi> what you wrote in the worker what the page sees load(series): void load(series): Promise<void> range(): Range range(): Promise<Range> movingAverage(n): Promise<Float64Array> movingAverage(n): Promise<Float64Array> readonly length: number readonly length: Promise<number> Already-async methods are not double-wrapped. Plain data properties become promises, because reading one is a message, not a memory access β€” the pumpkin row is the one that surprises people.
The rewrite is mechanical, and the last row is the design pressure: a chatty property surface turns into a chatty network surface.

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.

Where the type flows at build time versus where the code flows A build-time view with two outputs. The worker module contains the implementation and its heavy dependencies and is emitted as its own chunk loaded only by the Worker constructor. The page module imports the same file with import type, which is erased during compilation, so the page chunk contains only the client code and the one-point-one kilobyte Comlink runtime. A dashed arrow labelled erased at compile time connects the type import to the worker module, while a solid arrow shows the page fetching the worker chunk at runtime through new Worker. The type crosses at compile time; the code never crosses at all page chunk client code + Comlink (1.1 kB) import type β€” nothing emitted no parser, no WASM glue worker chunk Analysis class + dependencies Comlink.expose at top level fetched only by new Worker(...) type only β€” erased new Worker(url) Symptom of getting this wrong The page chunk grows by the size of the worker's dependency graph, the same code is downloaded twice, and first contentful paint regresses while the worker looks innocent. Enable verbatimModuleSyntax so an accidental value import fails the build instead of the budget.
A bundle analyser is the fastest confirmation: the worker's dependencies must appear in exactly one chunk, and it must not be the entry chunk.

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.

What the type checker verifies and what only the runtime knows Two bands. The upper band, checked at compile time, lists method names and arity, argument and return types, promise rewriting of every member, and whether the API export drifted from the implementation. The lower band, discovered only at runtime, lists whether a value is structured-cloneable, whether a transferred buffer is already detached, whether the exposed object was registered before the first call arrived, and whether both threads registered the same transfer handlers. Two layers of contract, only one of them checked Compile time β€” the type system has this covered method names and arity Β· argument and return types Β· promise rewriting of every member Β· drift between the exported API type and the implementation it is derived from Runtime β€” only tests and telemetry will tell you is this value structured-cloneable? Β· is the buffer already detached? did expose() run before the first call? Β· did both threads register the same transfer handlers, in the same order, from the same shared module? One integration test that exercises every method once catches the entire lower band.
The lower band is why a typed contract does not remove the need for a smoke test that actually crosses the boundary β€” one call per method is enough.

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.

Frequently Asked Questions

Why does TypeScript say my remote method returns Promise<Promise<T>>?
It does not β€” Remote<T> uses Promisify, which collapses a method already returning Promise<T> to Promise<T> rather than nesting. If you are seeing a nested promise, you have wrapped the API type by hand somewhere (Remote<Promise<Api>>, or a method typed as returning Remote<X>). Type the worker API with ordinary synchronous or async signatures and let Comlink.wrap<Api> do the rewriting once.
Do I need the worker's implementation types on the main thread?
Only as types. Use import type { Api } from './worker' so the import is erased at compile time and no worker code β€” no parser, no WASM glue, no heavy dependency β€” is pulled into the page bundle. If you see worker dependencies in the page chunk, an accidental value import is the cause; enable verbatimModuleSyntax or isolatedModules to make that mistake a compile error.

See also