Skip to content

Commit 021bf3c

Browse files
feat(learn): add article for publishing a typescript package (#7279)
* feat(learn): add article for publishing a typescript package * WIP: initial content for article * WIP: polish sample code, & dir overviews * rename article to be more specific * fix unsuported lang * fix navigation.json Co-authored-by: Augustin Mauroy <[email protected]> Signed-off-by: Jacob Smith <[email protected]> * fix links * extract note from codeblock into article * tidy codeblocks * wordsmith * fixup!: remove controversial "optionalDependencies" * fixup!: wordsmith & align code samples * fixup!: tsconfig * fixup!: switch sequence of repo vs package * fixup!: note types and unit tests are complementary * fixup!: `IDE` → `editor` * fixup!: note file extensions in package.json fields (js vs ts) * fixup!: add alternative samples & configs * fixup!: remove version from npm links * fixup!: shorter code sample display names * fixup!: add note about `NPM_TOKEN` * fixup!: switch node version matrix to LTS matrix action * fixup!: shorten displayNames (they were breaking page layout) * fixup!: update references to samples * fixup!: replace `npm publish` step from `publish.yml` with note * fixup!: replace ref to TS's own publishing guide with generic intro * fixup!: add note about `erasableSyntaxOnly` * fixup!: restore box vert lines * fixup!: add "dist output" tsconfig sample * fixup!: handle flavours of `.ts` file extensions in `.gitignore` sample * fixup!: correct code block lang for gitignore samples * fixup!: remove extra word * enable `ini` lang in codeblocks * fixup!: expand description of article's purpose * fixup!: adjust `tsconfig`s to support oldest node LTS version * fixup!: ts project → ts package --------- Signed-off-by: Jacob Smith <[email protected]> Co-authored-by: Augustin Mauroy <[email protected]>
1 parent dcdd2a6 commit 021bf3c

File tree

4 files changed

+342
-1
lines changed

4 files changed

+342
-1
lines changed

apps/site/navigation.json

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -209,6 +209,10 @@
209209
"runNatively": {
210210
"link": "/learn/typescript/run-natively",
211211
"label": "components.navigation.learn.typescript.links.runNatively"
212+
},
213+
"publishingTSPackage": {
214+
"link": "/learn/typescript/publishing-a-ts-package",
215+
"label": "components.navigation.learn.typescript.links.publishingTSPackage"
212216
}
213217
}
214218
},
Lines changed: 332 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,332 @@
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.

apps/site/shiki.config.mjs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,12 +2,14 @@
22

33
import diffLanguage from 'shiki/langs/diff.mjs';
44
import dockerLanguage from 'shiki/langs/docker.mjs';
5+
import iniLanguage from 'shiki/langs/ini.mjs';
56
import javaScriptLanguage from 'shiki/langs/javascript.mjs';
67
import jsonLanguage from 'shiki/langs/json.mjs';
78
import powershellLanguage from 'shiki/langs/powershell.mjs';
89
import shellScriptLanguage from 'shiki/langs/shellscript.mjs';
910
import shellSessionLanguage from 'shiki/langs/shellsession.mjs';
1011
import typeScriptLanguage from 'shiki/langs/typescript.mjs';
12+
import yamlLanguage from 'shiki/langs/yaml.mjs';
1113
import shikiNordTheme from 'shiki/themes/nord.mjs';
1214

1315
/**
@@ -22,13 +24,15 @@ export const LANGUAGES = [
2224
// that are commonly used (non-standard aliases) within our API docs and Blog posts
2325
aliases: javaScriptLanguage[0].aliases.concat('cjs', 'mjs'),
2426
},
27+
...iniLanguage,
2528
...jsonLanguage,
2629
...typeScriptLanguage,
2730
...shellScriptLanguage,
2831
...powershellLanguage,
2932
...shellSessionLanguage,
3033
...dockerLanguage,
3134
...diffLanguage,
35+
...yamlLanguage,
3236
];
3337

3438
// This is the default theme we use for our Shiki Syntax Highlighter

packages/i18n/locales/en.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,8 @@
5252
"introduction": "Introduction to TypeScript",
5353
"transpile": "Running TypeScript code using transpilation",
5454
"run": "Running TypeScript with a runner",
55-
"runNatively": "Running TypeScript Natively"
55+
"runNatively": "Running TypeScript Natively",
56+
"publishingTSPackage": "Publishing a TypeScript package"
5657
}
5758
},
5859
"asynchronousWork": {

0 commit comments

Comments
 (0)