Structuring a TypeScript project with workers

How should I structure a project that includes main thread (DOM) script, and workers? Eg:

main.ts

// This file must have DOM types, but not worker types.

const worker = new Worker('worker.js');

worker.onmessage = (event) => {
  // Ideally, I should be able to reference types from the worker:
  const data = event.data as import('./worker').HelloMessage;
  console.log(data.hello);
};

worker.ts

// This file must have worker types, but not DOM types.
// The global object must be one of the worker globals (how do I pick which?)

const helloMessage = {
  hello: 'world',
};

export type HelloMessage = typeof helloMessage;

postMessage(helloMessage);

Whenever I've tried this in the past I feel like I've been fighting TypeScript by either:

  • Using one tsconfig.json that has both worker & DOM types. But of course this isn't accurate type-wise.
  • Using multiple tsconfig.json. But then the project boundary makes it hard to reference types between them.

Additionally, how do I declare the global in a worker? Previously I've used declare var self: DedicatedWorkerGlobalScope, but is there a way to actually set the global (rather than just setting self)?


Solution 1:

Many thanks to Mattias Buelens who pointed me in the right direction.

Here's a working example.

The project structure is:

  • dist
  • src
    • generic-tsconfig.json
    • main
      • (typescript files)
      • tsconfig.json
    • dedicated-worker
      • (typescript files)
      • tsconfig.json
    • service-worker
      • (typescript files)
      • tsconfig.json

src/generic-tsconfig.json

This contains the config common to each project:

{
  "compilerOptions": {
    "target": "esnext",
    "module": "esnext",
    "strict": true,
    "moduleResolution": "node",
    "rootDir": ".",
    "outDir": "../dist",
    "composite": true,
    "declarationMap": true,
    "sourceMap": true
  }
}

I've deliberately avoided calling this tsconfig.json, as it isn't a project itself. Adapt the above to your needs. Here are the important parts:

  • outDir - This is where the transpiled script, declarations and source maps will go.
  • rootDir - By setting this to the src directory, each of the subprojects (main, dedicated-worker, service-worker) will appear as subdirectories in the outDir, otherwise they'll try and share the same directory and overwrite each other.
  • composite - This is required for TypeScript to keep references between projects.

Do not include references in this file. They'll be ignored for some undocumented reason (this is where I got stuck).

src/main/tsconfig.json

This is the config for the 'main thread' project, As in, the JavaScript that will have access to the document.

{
  "extends": "../generic-tsconfig.json",
  "compilerOptions": {
    "lib": ["esnext", "dom"],
  },
  "references": [
    {"path": "../dedicated-worker"},
    {"path": "../service-worker"}
  ]
}
  • extends - This points to our generic config above.
  • compilerOptions.lib - The libs used by this project. In this case, JS and the DOM.
  • references - Since this is the main project (the one we build), it must reference all other subprojects to ensure they're also built.

src/dedicated-worker/tsconfig.json

This is the config for the dedicated worker (the kind you create with new Worker()).

{
  "extends": "../generic-tsconfig.json",
  "compilerOptions": {
    "lib": ["esnext", "webworker"],
  }
}

You don't need to reference the other sub projects here unless you import things from them (eg types).

Using dedicated worker types

TypeScript doesn't differentiate between different worker contexts, despite them having different globals. As such, things get a little messy:

postMessage('foo');

This works, as TypeScript's "webworker" types create globals for all dedicated worker globals. However:

self.postMessage('foo');

…this fails, as TypeScript gives self a non-existent type that's sort-of an abstract worker global.

To fix this, include this in your source:

declare var self: DedicatedWorkerGlobalScope;
export {};

This sets self to the correct type.

The declare var bit doesn't work unless the file is a module, and the dummy export makes TypeScript treat it as a module. This means you're declaring self in the module scope, which doesn't currently exist. Otherwise, you're trying to declare it on the global, where it does already exist.

src/service-worker/tsconfig.json

Same as above.

{
  "extends": "../generic-tsconfig.json",
  "compilerOptions": {
    "lib": ["esnext", "webworker"],
  }
}

Using service worker types

As above, TypeScript's "webworker" types create globals for all dedicated worker globals. But this isn't a dedicated worker, so some of the types are incorrect:

postMessage('yo');

TypeScript doesn't complain about the above, but it'll fail at runtime as postMessage isn't on the service worker global.

Unfortunately there's nothing you can do to fix the real global, but you can still fix self:

declare var self: ServiceWorkerGlobalScope;
export {};

Now you need to ensure every global that's special to service workers is accessed via self.

addEventListener('fetch', (event) => {
  // This is a type error, as the global addEventListener
  // doesn't know anything about the 'fetch' event.
  // Therefore it doesn't know about event.request.
  console.log(event.request);
});

self.addEventListener('fetch', (event) => {
  // This works fine.
  console.log(event.request);
});

The same issues and workarounds exist for other worker types, such as worklets and shared workers.

Building

tsc --build src/main

And that's it!