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 thesrc
directory, each of the subprojects (main
,dedicated-worker
,service-worker
) will appear as subdirectories in theoutDir
, 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!