Strongly Typed Web and Service Workers with TypeScript

I’m writing this post because I had trouble finding a straightforward answer to this question: what are the current ways to use TypeScript with Service Worker files? And, to take it a step further, how to use those types in JavaScript, with JSDoc comments?

The examples in this post are specifically for a Service Worker script, but in general the solutions and issues in this post apply to web workers in general as well.

The Basics of Where Things are At

First, before getting into the solutions, I want to bring up a few things that I found in my research that are important to note. Keep in mind that these things might change, so this is kind of a “here things are now” section:

  • You no longer need external type definitions for web workers / service workers
    • A lot of older posts and StackOverflow answers will suggest things like copying and pasting code out of a Gist, using @types (DefinitelyTyped), or some other external type defs. These used to be necessary, but Web Worker types are now shipped by default with TypeScript, in the lib file lib.webworker.d.ts. How to use this file will be discussed in the solutions below.
  • There are some big open issues with Web Worker types that are blocking easier solutions for strong-typing service worker files.
    • These will be discussed again, throughout this post, but the major ones are:
      • #14877: The definition for self is not resolved correctly
      • #20595: Conflicts with DOM vs WebWorker
        • Related – #11093: Intersection of libs
        • If this issue is resolved, depending on how it is fixed, the solution to using TS with web workers might become greatly simplified

Solution A: Triple Slash Directives

Since a Service Worker often lives in a single file, some users prefer to pull in its lib types via a triple slash directives instead of modifying the tsconfig.

/// <reference no-default-lib="true"/>
/// <reference lib="ES2015" />
/// <reference lib="webworker" />

// Default type of `self` is `WorkerGlobalScope & typeof globalThis`
// https://github.com/microsoft/TypeScript/issues/14877
declare var self: ServiceWorkerGlobalScope;

// Example:
self.addEventListener('install', () => {
    //
});

// We need an export to force this file to act like a module, so TS will let us re-type `self`
export default null;

lib=ES2015 should be changed to whatever you are using, but it should be an es5+ choice

🚨 Uh-oh! Triple-slash directives don’t play nice with other files that use the regular DOM!

If you have noticed that using triple-slash directives to bring in Web Worker types in your service worker file also messed up your regular TS files, you are not imagining things. For a pure TS solution, skip to solution B, which explores multiple scoped TSConfigs.

But, for JS users, we can bypass this tricky issue; read on below!

Triple-Slash Directives with JavaScript and JSDoc

Here is the same approach as above, but carried out with JavaScript, using JSDoc for types:

// @ts-check
/// <reference no-default-lib="false"/>
/// <reference lib="ES2015" />
/// <reference lib="webworker" />

// Using IIFE to provide closure to redefine `self`
(() => {
    // This is a little messy, but necessary to force type assertion
    // Same issue as in TS -> https://github.com/microsoft/TypeScript/issues/14877
    // prettier-ignore
    const self = /** @type {ServiceWorkerGlobalScope} */ (/** @type {unknown} */ (globalThis.self));
    self.addEventListener('install', (evt) => {
        // ...
    });
})();

💡 Ah-ha! In implementing this solution I have found a trick around the issue of triple-slash directives polluting other files.

The trick is to exclude the JS file from your tsconfig and then add back checking by using //@ts-check at the top. This prevents the triple slash directives in the file from “bleeding” into the other files that are not excluded by the tsconfig. This will prevent things like window becoming undefined or all instances of globalThis.self getting changed to the Service Worker Type!

lib=ES2015 should be changed to whatever you are using, but it should be an es5+ choice

Solution B: TSConfig Libs

Triple slash directives are not always the best approach, especially since they can “bleed” into other files anyways when you are using them with libs (they essentially act like global lib options – see docs). So, there is not much benefit over using the --lib flag or lib option in tsconfig.

Relevant issue: #20595

In fact, the best option might be to have a different tsconfig for your service worker, versus for all other files. For example:

Base tsconfig.json:

{
    "compilerOptions": {
        "target": "ES6"
    },
    "exclude": ["service-worker.js"]
}

Service-Worker tsconfig.service-worker.json:

{
    "extends": "./tsconfig",
    "compilerOptions": {
        "lib": ["WebWorker", "ES2015"]
    },
    "files": ["service-worker.js"]
}

If you are looking for a more detailed breakdown of this approach, check out this StackOverflow answer.

Nested Service Worker Folder

The above option works if you are only using TypeScript via CLI, and not for Intellisense, as VSCode does not resolve more than one tsconfig per folder. If you wanted to use the above solution with VSCode and get full in-IDE type-checking, you would need to move service-worker.js to a sub-directory, with its tsconfig file located alongside it.

However, it should be noted that this causes its own issue, and in fact, a big one. Service workers by default are granted a scope based on file location. So a worker located in the root directory:

.
├── index.html
├── tsconfig.json
└── service-worker.{ts|js}

Can access any request. However, if we move to a subfolder:

.
├── index.html
├── tsconfig.json
└── sw/
    ├── service-worker.{ts|js}
    └── tsconfig.json

… now our service worker has a default scope of /sw, and declaring a higher scope actually requires configuring our server to send a unique HTTP header with any request for our worker script!

The easiest fix is probably to use outDir or outFile to make sure that the transpiled SW script ends up in the hosted project root, regardless of the source code directory structure.

Leave a Reply

Your email address will not be published. Required fields are marked *