Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(learn): add article for publishing a typescript package #7279

Open
wants to merge 17 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions apps/site/navigation.json
Original file line number Diff line number Diff line change
Expand Up @@ -209,6 +209,10 @@
"runNatively": {
"link": "/learn/typescript/run-natively",
"label": "components.navigation.learn.typescript.links.runNatively"
},
"publishingTSProject": {
"link": "/learn/typescript/publishing-a-ts-project",
"label": "components.navigation.learn.typescript.links.publishing-a-ts-project"
JakobJingleheimer marked this conversation as resolved.
Show resolved Hide resolved
}
}
},
Expand Down
225 changes: 225 additions & 0 deletions apps/site/pages/en/learn/typescript/publishing-a-ts-project.md
himself65 marked this conversation as resolved.
Show resolved Hide resolved
Original file line number Diff line number Diff line change
@@ -0,0 +1,225 @@
---
title: Publishing a TypeScript project
layout: learn
authors: JakobJingleheimer
---

# Publishing a TypeScript project

This article augments TypeScript's own [Publishing guide](https://www.typescriptlang.org/docs/handbook/declaration-files/publishing.html) with specifics for native node support.

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I am sort of questioning linking to this page; reading it, it's pretty out of date and is part of the declaration file section, so sort of misses out on other important stuff.


Some important things to note:

- Everything from [][../modules/publishing-a-package] applies here.

- Node runs TypeScript 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 of node version 23.6.0.

- Node does **not** strip types in `node_modules` because it can cause significant performance issues for the official TypeScript compiler (`tsc`), so the TypeScript maintainers would like to discourage people publishing raw TypeScript, at least for now.
JakobJingleheimer marked this conversation as resolved.
Show resolved Hide resolved

- Consuming TypeScript-specific features like `enum` in node still require a flag ([`--experimental-transform-types`](https://nodejs.org/api/typescript.html#typescript-features)). There are often better alternatives for these anyway.

- 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.

- `.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.

A published package will look something like:

```text displayName="Published example TypeScript package (directory overview)"
example-ts-pkg/
├ LICENSE
├ main.d.ts
├ main.js
├ package.json
├ README.md
├ some-util.d.ts
└ some-util.js
```

That would be derived from a repository looking something like:

```text displayName="Source of the example TypeScript package (directory overview)"
example-ts-pkg/
├ .github/
┆ ├ workflows/
┆ ┆ ├ ci.yml
┆ ┆ └ publish.yml
┆ └ dependabot.yml
├ src/
┆ ├ foo.fixture.js
┆ ├ main.ts
┆ ├ main.test.ts
┆ ├ some-util.ts
┆ └ some-util.test.ts
├ LICENSE
├ package.json
└ README.md
```

## What to do with your types

### Treat types like a test
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We should add a note: types do not sobstitute unit testing

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe just avoid mentioning "treat types like tests" in the first place? I don't really see how it weaves into the blog post (even in this section)

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

IMO, we should treat types as a part of code quality checking. Like lintting and formatting

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We should add a note: types do not sobstitute unit testing


The purpose of types is to warn an implementation will not work:

```ts
const foo = 'a';
const bar: number = 1 + foo;
// ^^^ Type 'string' is not assignable to type 'number'.
```

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.

Your IDE (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.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
Your IDE (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.
Your editor (e.g. 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.


```yaml displayName=".github/workflows/ci.yml"
name: Tests

on:
pull_request:
branches: ['main']

jobs:
check-types:
# Separate these from tests because
# they are platform and node-version independent
# and need be run only once.

runs-on: ubuntu-latest

steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version-file: '.nvmrc'
cache: 'npm'
- name: npm clean install
run: npm ci
# You may want to run a lint check here too
- run: node --run types:check

test:
runs-on: ubuntu-latest

strategy:
matrix:
node:
- version: 23.x
- version: 22.x
fail-fast: false # Prevent a failure in one version cancelling other runs

steps:
- uses: actions/checkout@v4
- name: Use node ${{ matrix.node.version }}
uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node.version }}
cache: 'npm'
- name: npm clean install
run: npm ci
- run: node --run test
```

```json displayName="package.json"
{
"name": "example-ts-pkg",
"scripts": {
"test": "node --test",
"types:check": "tsc --noEmit"

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I noted this in the other thread, but I would be cautious about this as a default; I really only see people setting noEmit when they're doing a quick check, or are using a bundler or something.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is the type "test" command. Why would you want to emit a compilation?

Maybe the names could be better? When I have unit and end-to-end tests with different setups, I split those into different commands like:

  • test:unit
  • test:e2e

So in that scenario, it could make sense to name types:checktest:types.

But in the sample, there's no differentiation between units and e2e, so then what do I call what is currently test?

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I mean, I guess it's fine, I am just wary of cases where tsc and tsc --noEmit output different errors because the former is doing more. Maybe you'd hit it on prepack and that's okay, but it's a little unfortunate to only hit an error when you go to release...

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is that likely? I've been doing this for years and never encountered that—am I just very lucky?

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not sure how to gauge "likely", probably unlikely, but they're cases like "tsc failed to write the files", along with potentially some declaration transform errors. (The latter shouldn't actually end up mattering by my reading of the code, though.)

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The specific issues that come up that I can think of are when a declaration file can't reliably be generated because doing so might require referencing entities that are private or non-exported. Trying to figure out why this error is happening can be pretty frustrating, especially if you've been relying a specific pattern over time. Having a divergence between publish/CI probably just makes this even more confusing since most people outside of the person who set up the build won't be aware of any differences.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

tsc failed to write the files

That does sound like an issue that you legitimately wouldn't catch with a dry-run, but also seems a very unlikely issue (and one that could still occur at publishing even if you were using non-dry-run for the test step). It seems an edge-case worth noting but not worth taking a hit every time to avoid something that likely will never happen (if it does, there are a very limited number of causes—two? permissions and storage availability).

The specific issues that come up that I can think of are when a declaration file can't reliably be generated because doing so might require referencing entities that are private or non-exported.

That sounds very detectable for --noEmit; if it doesn't do that, that sounds like a defect in tsc? Why would you need to writing to disk in order to discover it?


But maybe let's take a step back for a second: The reason I wrote the setup this way is because of performance—but perhaps my information is outdated. Last I heard, tsc --noEmit was significantly faster than with emit.

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That sounds very detectable for --noEmit; if it doesn't do that, that sounds like a defect in tsc? Why would you need to writing to disk in order to discover it?

I think as of recent releases (since --isolatedDeclarations), we've actually always checked declaration errors without emit, so I think the only errors one can see differently are just the errors that happen while writing, which may not really be important except in the case where you've somehow accidentally marked output paths as readonly in the FS or something.

But maybe let's take a step back for a second: The reason I wrote the setup this way is because of performance—but perhaps my information is outdated. Last I heard, tsc --noEmit was significantly faster than with emit.

It can be, though I think at the scale of this demo, it's definitely not a big difference.

},
"optionalDependencies": {
// This is used only in CI.
// Avoid bloating your local node_modules
// (where you probably won't use it) by
// running `npm install --omit="optional"`
JakobJingleheimer marked this conversation as resolved.
Show resolved Hide resolved
"typescript": "^5.7.2"
}
JakobJingleheimer marked this conversation as resolved.
Show resolved Hide resolved
}
```

```json displayName="tsconfig.json"
{
"compilerOptions": {
"allowArbitraryExtensions": true,
"allowImportingTsExtensions": true,
JakobJingleheimer marked this conversation as resolved.
Show resolved Hide resolved
"baseUrl": "./",
JakobJingleheimer marked this conversation as resolved.
Show resolved Hide resolved
"declaration": true,
"declarationMap": true,
"esModuleInterop": true, // Flux Capacitor: The universe breaks without it, but nobody knows exactly what it does.
JakobJingleheimer marked this conversation as resolved.
Show resolved Hide resolved
"lib": ["ESNext"],
"module": "NodeNext",
"moduleResolution": "NodeNext",
"outDir": "./",

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is the default, but we generally recommend setting outDir to something else. ("rootDir": "src", "outDir": "dist" is common.)

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, I was also about to comment that when I noticed the flat layout above.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What is the reason for that recommendation?

"outDir": "dist" will cause that folder to get created and published though, whereas I want it to be in just the root to avoid unnecessary drilling.

So I think I want neither "rootDir": "src" nor "outDir": "dist", because that will double what I want to avoid.

Copy link

@jakebailey jakebailey Jan 8, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Well, it at least implies that any large project is just going to dump a load of files at the root, which are going to be very annoying to gitignore/npmignore/eslitignore/prettierignore/etc, and visually ignore in the repo. If you output to dist, they're all in one place, easy to ignore, and notably, less likely to be accidentally loaded by TS or something.

I haven't personally seen a project which used a src and then dumped files at the root, only src -> dist, or, no outDir and allow the emitted JS to live next to the TS.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It also makes it easier to throw away all the files for a clean build. With package.json exports, it feels like there really isn't much reason to publish at the root anymore.

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can you say what those issues are? The only thing I can think of is compat with old node releases that do not support exports, or trying to support people who want to deep import when the package author does not want that.

Copy link
Member

@DanielRosenwasser DanielRosenwasser Jan 8, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, it preserves structure, but that feels beside the point. But by and large, the recommendation we've had (and really the de facto norm at this point) is to use a dedicated output directory.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's very atypical for anyone to run tsc --noEmit and then also run tsc and publish its outputs.

Maaybe there's a misunderstanding: these would not be run one right after the other:

  • On a PR, tsc --noEmit would run.
  • On a release, tsc would run.

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I understand that, I'm just saying that the two may behave differently as there are errors that can only happen when tsc is emitting, and you would never see them until tsc is run without --noEmit.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

On a PR, tsc --noEmit would run.

Let me just say that a release-blocker because of .d.ts emit problems is better caught as early as possible, and the delta in CI time is not worth it.

"resolveJsonModule": true,
"resolvePackageJsonExports": true,
"resolvePackageJsonImports": true,
"target": "ESNext"
},
// These may be different for your repo:
DanielRosenwasser marked this conversation as resolved.
Show resolved Hide resolved
"include": ["./src"],
"exclude": ["**/*/*.test.*", "**/fixtures/**"]
}
```

### Generate type declarations

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.

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.

Take the following example (a [GitHub Action](https://github.com/features/actions)), where the type declarations are generated just before publishing to the NPM registry.

```yaml displayName=".github/workflows/publish.yml"
name: Publish to NPM
on:
push:
tags:
- '**@*'
Comment on lines +171 to +175
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
name: Publish to NPM
on:
push:
tags:
- '**@*'
name: Publish to NPM
on:
release:
types: [published]

We can propose to just use release not tag. In some case tag is useful for example when you have monorepo

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

? Package releases should always have a git tag, no exceptions - single-package repos too.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I definitely think a release should have a tag, but I think different points are being argued here: It's not saying a release shouldn't have a tag, but is instead saying make this relative to the release.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ahhh i see, i was confused. yeah i think it's fine to trigger on release.

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

FWIW you cannot create a GitHub release without pointing it at a tag; the UI will force you to either select one or make one, so a tag will definitely have existed.


jobs:
build:
runs-on: ubuntu-latest

permissions:
contents: read
id-token: write
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version-file: '.nvmrc'
registry-url: 'https://registry.npmjs.org'
- run: npm ci

# You can probably ignore the boilerplate config above

- name: Publish with provenance
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
run: npm publish --access public --provenance
```

```diff displayName="package.json"
{
"name": "example-ts-pkg",
"scripts": {
+ "prepack": "tsc",
"types:check": "tsc --noEmit"
}
}
```

```text displayName=".npmignore"
*.ts
!*.d.ts
fixtures
```

`npm publish` will automatically run [`prepack` beforehand](https://docs.npmjs.com/cli/v11/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](https://nodejs.org/api/cli.html#intentional-limitations). You can't use `node --run` for this step, so that is not a caveat here, but it can be for other steps.

#### Breaking this down

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.

[`npm publish`](https://docs.npmjs.com/cli/v11/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.

By default, `npm publish` grabs (almost) everything (see [Files included in package](https://docs.npmjs.com/cli/v11/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/v11/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/v11/configuring-npm/package-json#files) to create an opt-in list.
Copy link
Member

@DanielRosenwasser DanielRosenwasser Jan 8, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

By default, npm publish grabs (almost) everything (see Files included in package).

Similar to what @andrewbranch and @jakebailey said above, if you specify an --outDir, then you can use the package.json "files" array to avoid other hazards.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

or .npmignore, which avoids hazards endemic to files :-)

3 changes: 2 additions & 1 deletion packages/i18n/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,8 @@
"introduction": "Introduction to TypeScript",
"transpile": "Running TypeScript code using transpilation",
"run": "Running TypeScript with a runner",
"runNatively": "Running TypeScript Natively"
"runNatively": "Running TypeScript Natively",
"publishingTSProject": "Publishing a TypeScript project"
}
},
"asynchronousWork": {
Expand Down
Loading