|
| 1 | +--- |
| 2 | +title: Publishing a TypeScript package |
| 3 | +layout: learn |
| 4 | +authors: JakobJingleheimer |
| 5 | +--- |
| 6 | + |
| 7 | +# Publishing a TypeScript package |
| 8 | + |
| 9 | +This article covers items regarding TypeScript publishing specifically. Publishing means distributed as a package via npm (or other package manager); this is not about compiling an app / server to be run in production (such as a PWA and/or endpoint server). |
| 10 | + |
| 11 | +Some important things to note: |
| 12 | + |
| 13 | +- Everything from [Publishing a package](../modules/publishing-a-package) applies here. |
| 14 | + |
| 15 | + - Fields like `main` operate on _published_ content, so when TypeScript source-code is transpiled to JavaScript, JavaScript is the published content and `main` would point to a JavaScript file with a JavaScript file extension (ex `main.ts` → `"main": "main.js"`). |
| 16 | + |
| 17 | + - Fields like `scripts.test` operate on source-code, so they would use the file extensions of the source code (ex `"test": "node --test './src/**/*.test.ts'`). |
| 18 | + |
| 19 | +- Node runs TypeScript code via a process called "[type stripping](https://nodejs.org/api/typescript.html#type-stripping)", wherein node (via [Amaro](https://github.com/nodejs/amaro)) removes TypeScript-specific syntax, leaving behind vanilla JavaScript (which node already understands). This behaviour is enabled by default as of node version 23.6.0. |
| 20 | + |
| 21 | + - Node does **not** strip types in `node_modules` because it can cause significant performance issues for the official TypeScript compiler (`tsc`) and parts of VS Code, so the TypeScript maintainers would like to discourage people publishing raw TypeScript, at least for now. |
| 22 | + |
| 23 | +- Consuming TypeScript-specific features like `enum` in node still requires a flag ([`--experimental-transform-types`](https://nodejs.org/api/typescript.html#typescript-features)). There are often better alternatives for these anyway. |
| 24 | + |
| 25 | + - To ensure TypeScript-specific features are _not_ present (so your code can just run in node), set the [`erasableSyntaxOnly`](https://devblogs.microsoft.com/typescript/announcing-typescript-5-8-beta/#the---erasablesyntaxonly-option) config option in TypeScript version 5.8+. |
| 26 | + |
| 27 | +- Use [dependabot](https://docs.github.com/en/code-security/dependabot) to keep your dependencies current, including those in github actions. It's a very easy set-and-forget configuration. |
| 28 | + |
| 29 | +- `.nvmrc` comes from [NVM](https://github.com/nvm-sh/nvm), a multi-version manager for node. It allows you to specify the version of node the project should generally use. |
| 30 | + |
| 31 | +A directory overview of a repository would look something like: |
| 32 | + |
| 33 | +```text displayName="Files co-located" |
| 34 | +example-ts-pkg/ |
| 35 | +├ .github/ |
| 36 | +│ ├ workflows/ |
| 37 | +│ │ ├ ci.yml |
| 38 | +│ │ └ publish.yml |
| 39 | +│ └ dependabot.yml |
| 40 | +├ src/ |
| 41 | +│ ├ foo.fixture.js |
| 42 | +│ ├ main.ts |
| 43 | +│ ├ main.test.ts |
| 44 | +│ ├ some-util.ts |
| 45 | +│ └ some-util.test.ts |
| 46 | +├ LICENSE |
| 47 | +├ package.json |
| 48 | +├ README.md |
| 49 | +└ tsconfig.json |
| 50 | +``` |
| 51 | + |
| 52 | +```text displayName="Files co-located but segregated" |
| 53 | +example-ts-pkg/ |
| 54 | +├ .github/ |
| 55 | +│ ├ workflows/ |
| 56 | +│ │ ├ ci.yml |
| 57 | +│ │ └ publish.yml |
| 58 | +│ └ dependabot.yml |
| 59 | +├ src/ |
| 60 | +│ ├ __test__/ |
| 61 | +│ │ ├ foo.fixture.js |
| 62 | +│ │ ├ main.test.ts |
| 63 | +│ ├ main.ts |
| 64 | +│ └ some-util.ts |
| 65 | +│ │ ├ __test__ |
| 66 | +│ │ └ some-util.test.ts |
| 67 | +│ │ └ some-util.ts |
| 68 | +├ LICENSE |
| 69 | +├ package.json |
| 70 | +├ README.md |
| 71 | +└ tsconfig.json |
| 72 | +``` |
| 73 | + |
| 74 | +```text displayName="'src' and 'test' fully segregated" |
| 75 | +example-ts-pkg/ |
| 76 | +├ .github/ |
| 77 | +│ ├ workflows/ |
| 78 | +│ │ ├ ci.yml |
| 79 | +│ │ └ publish.yml |
| 80 | +│ └ dependabot.yml |
| 81 | +├ src/ |
| 82 | +│ ├ main.ts |
| 83 | +│ ├ some-util.ts |
| 84 | +├ test/ |
| 85 | +│ ├ foo.fixture.js |
| 86 | +│ ├ main.ts |
| 87 | +│ └ some-util.ts |
| 88 | +├ LICENSE |
| 89 | +├ package.json |
| 90 | +├ README.md |
| 91 | +└ tsconfig.json |
| 92 | +``` |
| 93 | + |
| 94 | +And a directory overview of its published package would look something like: |
| 95 | + |
| 96 | +```text displayName="Fully flat" |
| 97 | +example-ts-pkg/ |
| 98 | +├ LICENSE |
| 99 | +├ main.d.ts |
| 100 | +├ main.d.ts.map |
| 101 | +├ main.js |
| 102 | +├ package.json |
| 103 | +├ README.md |
| 104 | +├ some-util.d.ts |
| 105 | +├ some-util.d.ts.map |
| 106 | +└ some-util.js |
| 107 | +``` |
| 108 | + |
| 109 | +```text displayName="With 'dist'" |
| 110 | +example-ts-pkg/ |
| 111 | +├ dist/ |
| 112 | +│ ├ main.d.ts |
| 113 | +│ ├ main.d.ts.map |
| 114 | +│ ├ main.js |
| 115 | +│ ├ some-util.d.ts |
| 116 | +│ ├ some-util.d.ts.map |
| 117 | +│ └ some-util.js |
| 118 | +├ LICENSE |
| 119 | +├ package.json |
| 120 | +└ README.md |
| 121 | +``` |
| 122 | + |
| 123 | +A note about directory organisation: There are a few common practices for placing tests. Principle of least knowledge says to co-locate them (put them adjacent to implementation). Sometimes, that's in the same directory, or within a drawer like a `__test__` (also adjacent to the implementation, "Files co-located but segregated"). Alternatively, some opt to create a `test/` sibling to `src/` ("'src' and 'test' fully segregated"), either with a mirrored structure or a "junk drawer". |
| 124 | + |
| 125 | +## What to do with your types |
| 126 | + |
| 127 | +### Treat types like a test |
| 128 | + |
| 129 | +The purpose of types is to warn an implementation will not work: |
| 130 | + |
| 131 | +```ts |
| 132 | +const foo = 'a'; |
| 133 | +const bar: number = 1 + foo; |
| 134 | +// ^^^ Type 'string' is not assignable to type 'number'. |
| 135 | +``` |
| 136 | + |
| 137 | +TypeScript has warned that the above code will not behave as intended, just like a unit test warns that code does not behave as intended. They are complementary and verify different things—you should have both. |
| 138 | + |
| 139 | +Your editor (ex VS Code) likely has built-in support for TypeScript, displaying errors as you work. If not, and/or you missed those, CI will have your back. |
| 140 | + |
| 141 | +The following [GitHub Action](https://github.com/features/actions) sets up a CI task to automatically check (and require) types pass inspection for a PR into the `main` branch. |
| 142 | + |
| 143 | +```yaml displayName=".github/workflows/ci.yml" |
| 144 | +# yaml-language-server: $schema=https://json.schemastore.org/github-workflow.json |
| 145 | + |
| 146 | +name: Tests |
| 147 | + |
| 148 | +on: |
| 149 | + pull_request: |
| 150 | + branches: ['*'] |
| 151 | + |
| 152 | +jobs: |
| 153 | + check-types: |
| 154 | + # Separate these from tests because |
| 155 | + # they are platform and node-version independent |
| 156 | + # and need be run only once. |
| 157 | + |
| 158 | + runs-on: ubuntu-latest |
| 159 | + |
| 160 | + steps: |
| 161 | + - uses: actions/checkout@v4 |
| 162 | + - uses: actions/setup-node@v4 |
| 163 | + with: |
| 164 | + node-version-file: '.nvmrc' |
| 165 | + cache: 'npm' |
| 166 | + - name: npm clean install |
| 167 | + run: npm ci |
| 168 | + # You may want to run a lint check here too |
| 169 | + - run: node --run types:check |
| 170 | + |
| 171 | + get-matrix: |
| 172 | + # Automatically pick active LTS versions |
| 173 | + runs-on: ubuntu-latest |
| 174 | + outputs: |
| 175 | + latest: ${{ steps.set-matrix.outputs.requireds }} |
| 176 | + steps: |
| 177 | + - uses: ljharb/actions/node/matrix@main |
| 178 | + id: set-matrix |
| 179 | + with: |
| 180 | + versionsAsRoot: true |
| 181 | + type: majors |
| 182 | + preset: '>= 22' # glob is not backported below 22.x |
| 183 | + |
| 184 | + test: |
| 185 | + needs: [get-matrix] |
| 186 | + runs-on: ${{ matrix.os }} |
| 187 | + |
| 188 | + strategy: |
| 189 | + fail-fast: false |
| 190 | + matrix: |
| 191 | + node-version: ${{ fromJson(needs.get-matrix.outputs.latest) }} |
| 192 | + os: |
| 193 | + - macos-latest |
| 194 | + - ubuntu-latest |
| 195 | + - windows-latest |
| 196 | + |
| 197 | + steps: |
| 198 | + - uses: actions/checkout@v4 |
| 199 | + - name: Use node ${{ matrix.node-version }} |
| 200 | + uses: actions/setup-node@v4 |
| 201 | + with: |
| 202 | + node-version: ${{ matrix.node-version }} |
| 203 | + cache: 'npm' |
| 204 | + - name: npm clean install |
| 205 | + run: npm ci |
| 206 | + - run: node --run test |
| 207 | +``` |
| 208 | +
|
| 209 | +```json displayName="package.json" |
| 210 | +{ |
| 211 | + "name": "example-ts-pkg", |
| 212 | + "scripts": { |
| 213 | + "test": "node --test './src/**/*.test.ts'", |
| 214 | + "types:check": "tsc --noEmit" |
| 215 | + }, |
| 216 | + "devDependencies": { |
| 217 | + "typescript": "^5.7.2" |
| 218 | + } |
| 219 | +} |
| 220 | +``` |
| 221 | + |
| 222 | +```json displayName="tsconfig.json (flat output)" |
| 223 | +{ |
| 224 | + "compilerOptions": { |
| 225 | + "allowArbitraryExtensions": true, |
| 226 | + "declaration": true, |
| 227 | + "declarationMap": true, |
| 228 | + "lib": ["es2023"], |
| 229 | + "module": "NodeNext", |
| 230 | + "outDir": "./", |
| 231 | + "resolveJsonModule": true, |
| 232 | + "rewriteRelativeImportExtensions": true, |
| 233 | + "target": "es2022" |
| 234 | + }, |
| 235 | + // These may be different for your repo: |
| 236 | + "include": ["./src"], |
| 237 | + "exclude": ["**/*/*.test.*", "**/*.fixture.*"] |
| 238 | +} |
| 239 | +``` |
| 240 | + |
| 241 | +```json displayName="tsconfig.json ('dist' output)" |
| 242 | +{ |
| 243 | + "compilerOptions": { |
| 244 | + "allowArbitraryExtensions": true, |
| 245 | + "declaration": true, |
| 246 | + "declarationMap": true, |
| 247 | + "lib": ["es2023"], |
| 248 | + "module": "NodeNext", |
| 249 | + "outDir": "./dist", |
| 250 | + "resolveJsonModule": true, |
| 251 | + "rewriteRelativeImportExtensions": true, |
| 252 | + "target": "es2022" |
| 253 | + }, |
| 254 | + // These may be different for your repo: |
| 255 | + "include": ["./src"], |
| 256 | + "exclude": ["**/*/*.test.*", "**/*.fixture.*"] |
| 257 | +} |
| 258 | +``` |
| 259 | + |
| 260 | +Note that test files may well have a different `tsconfig.json` applied (hence why they are excluded in the above sample). |
| 261 | + |
| 262 | +### Generate type declarations |
| 263 | + |
| 264 | +Type declarations (`.d.ts` and friends) provide type information as a sidecar file, allowing the execution code to be vanilla JavaScript whilst still having types. |
| 265 | + |
| 266 | +Since these are generated based on source code, they can be built as part of your publication process and do not need to be checked into your repository. |
| 267 | + |
| 268 | +Take the following example, where the type declarations are generated just before publishing to the NPM registry. |
| 269 | + |
| 270 | +```yaml displayName=".github/workflows/publish.yml" |
| 271 | +# yaml-language-server: $schema=https://json.schemastore.org/github-workflow.json |
| 272 | + |
| 273 | +# This is mostly boilerplate. |
| 274 | + |
| 275 | +name: Publish to NPM |
| 276 | +on: |
| 277 | + push: |
| 278 | + tags: |
| 279 | + - '**@*' |
| 280 | + |
| 281 | +jobs: |
| 282 | + build: |
| 283 | + runs-on: ubuntu-latest |
| 284 | + |
| 285 | + permissions: |
| 286 | + contents: read |
| 287 | + steps: |
| 288 | + - uses: actions/checkout@v4 |
| 289 | + - uses: actions/setup-node@v4 |
| 290 | + with: |
| 291 | + node-version-file: '.nvmrc' |
| 292 | + registry-url: 'https://registry.npmjs.org' |
| 293 | + - run: npm ci |
| 294 | + |
| 295 | + # - name: Publish to NPM |
| 296 | + # run: … npm publish … |
| 297 | +``` |
| 298 | + |
| 299 | +```diff displayName="package.json" |
| 300 | +{ |
| 301 | + "name": "example-ts-pkg", |
| 302 | + "scripts": { |
| 303 | ++ "prepack": "tsc", |
| 304 | + "types:check": "tsc --noEmit" |
| 305 | + } |
| 306 | +} |
| 307 | +``` |
| 308 | + |
| 309 | +```ini displayName=".npmignore" |
| 310 | +*.*ts # foo.cts foo.mts foo.ts |
| 311 | +!*.d.*ts |
| 312 | +*.fixture.* |
| 313 | +``` |
| 314 | + |
| 315 | +```ini displayName=".npmignore ('dist' output)" |
| 316 | +src |
| 317 | +test |
| 318 | +``` |
| 319 | + |
| 320 | +You'll want to publish a package compiled to support all Node.js LTS versions since you don't know which version the consumer will be running; the `tsconfig`s in this article support node 18.x and later. |
| 321 | + |
| 322 | +`npm publish` will automatically run [`prepack` beforehand](https://docs.npmjs.com/cli/using-npm/scripts#npm-publish). `npm` will also run `prepack` automatically before `npm pack --dry-run` (so you can easily see what your published package will be without actually publishing it). **Beware**, [`node --run` does _not_ do that](../command-line/run-nodejs-scripts-from-the-command-line.md#using-the---run-flag). You can't use `node --run` for this step, so that caveat does not apply here, but it can for other steps. |
| 323 | + |
| 324 | +The steps to actually publish to npm will be included in a separate article (there are several pros and cons beyond the scope of this article). |
| 325 | + |
| 326 | +#### Breaking this down |
| 327 | + |
| 328 | +Generating type declarations is deterministic: you'll get the same output from the same input, every time. So there is no need to commit these to git. |
| 329 | + |
| 330 | +[`npm publish`](https://docs.npmjs.com/cli/commands/npm-publish) grabs everything applicable and available at the moment the command is run; so generating type declarations immediately before means those are available and will get picked up. |
| 331 | + |
| 332 | +By default, `npm publish` grabs (almost) everything (see [Files included in package](https://docs.npmjs.com/cli/commands/npm-publish#files-included-in-package)). In order to keep your published package minimal (see the "Heaviest Objects in the Universe" meme about `node_modules`), you want to exclude certain files (like tests and test fixtures) from from packaging. Add these to the opt-out list specified in [`.npmignore`](https://docs.npmjs.com/cli/using-npm/developers#keeping-files-out-of-your-package); ensure the `!*.d.ts` exception is listed, or the generated type declartions will not be published! Alternatively, you can use [package.json "files"](https://docs.npmjs.com/cli/configuring-npm/package-json#files) to create an opt-in list. |
0 commit comments