Tutorial: publishing ESM-based npm packages with TypeScript

[2025-02-04] dev, typescript
(Ad, please don’t block)

During the last two years, ESM support in TypeScript, Node.js and browsers has made a lot of progress. In this blog post, I explain my modern setup that is relatively simple – compared to what we had to do in the past:

  • It is intended for packages that can afford to ignore backward compatibility. The setup has worked well for me for a while – since TypeScript 4.7 (2022-05-24).
    • It helps that Node.js now supports “require(esm)” – requiring ESM libraries from CommonJS modules.
  • I’m only using tsc, but mention how to support other tools via tsconfig.json in section “Compiling TypeScript with tools other than tsc”.

Feedback welcome: What do you do differently? What can be improved?

Example package: @rauschma/helpers uses the setup described in this blog post.

File system layout  

Our npm package will have the following file system layout:

my-package/
  README.md
  LICENSE
  package.json
  tsconfig.json
  docs/
    api/
  src/
  test/
  dist/
    src/
    test/

Comments:

  • It’s usually a good idea to include a README.md and a LICENSE
  • package.json describes the package and is described later.
  • tsconfig.json configures TypeScript and is described later.
  • docs/api/ is for API documentation generated via TypeDoc. How to do that is described later.
  • src/ is for the TypeScript source code.
  • test/ is for unit tests (more on that soon).
  • dist/ is where TypeScript writes its output.

.gitignore  

I’m using Git for version control. This is my .gitignore (located inside my-package/)

node_modules
dist
.DS_Store

Why these entries?

  • node_modules: The most common practice currently seems to be not to check in the node_modules directory.
  • dist: The compilation output of TypeScript is not checked into Git, but it is uploaded to the npm registry. More on that later.
  • .DS_Store: This entry is about me being lazy as a macOS user. Since it’s only need on that operating system, you can argue that Mac people should add it via a global configuration setting and keep it out of project-specific gitignores.

Unit tests  

I have started to put the unit tests for a particular module next to that module:

src/
  util.ts
  util_test.ts

Given that unit tests help with understanding how a module works, they should be easy to find.

Tip for unit tests: self-reference the package  

If an npm package has "exports", it can self-reference them via its package name:

// util_test.js
import {helperFunc} from 'my-package/util.js';

The Node.js documentation has more information on self-referencing and notes: “Self-referencing is available only if package.json has "exports", and will allow importing only what that "exports" (in the package.json) allows.”

Benefits of self-referencing:

  • It is useful for tests (which can demonstrate how importing packages would use the code).
  • It checks if your package exports are set up properly.

tsconfig.json  

In this section, we’ll go through the highlights of tsconfig.json. Related material:

Where does the output go?  

{
  "include": ["src/**/*", "test/**/*"],
  "compilerOptions": {
    // Specify explicitly (don’t derive from source file paths):
    "rootDir": ".",
    "outDir": "dist",
    // ···
  }
}

Consequences of these settings:

  • Input: src/util.ts
    • Output: dist/src/util.js
  • Input: test/my-test_test.ts
    • Output: dist/test/my-test_test.js

Output  

Given a TypeScript file util.ts, tsc writes the following output to dist/src/:

src/
  util.ts
dist/src/
  util.js
  util.js.map
  util.d.ts
  util.d.ts.map

Purposes of these files:

  • util.js: JavaScript code contained in util.ts
  • util.js.map: source map for the JavaScript code. It enables JavaScript engines that support it to run util.js while showing the line numbers from util.ts (e.g.) in stack traces of exceptions.
    • tsconfig.json: "sourceMap": true
  • util.d.ts: types defined in util.ts
    • tsconfig.json: "declaration": true
  • util.d.ts.map: declaration map – a source map for util.d.ts. It enables TypeScript editors that support it to (e.g.) jump to the TypeScript source code of the definition of a type. I find that useful for libraries. It’s why I include the TypeScript source in their packages.
    • tsconfig.json: "declarationMap": true

Compiling TypeScript with tools other than tsc  

The TypeScript compiler performs three tasks:

  1. Type checking
  2. Emitting JavaScript files
  3. Emitting declaration files

There are now many tools that can do #2 and #3 faster than tsc. The following settings help those tools because they force us to use subsets of TypeScript that are easier to compile:

"compilerOptions": {
  //----- Helps with emitting .js -----
  // Enforces keyword `type` for type imports etc.
  "verbatimModuleSyntax": true, // implies "isolatedModules"
  // Forbids non-JavaScript language constructs such as
  // JSX, enums, constructor parameter properties and namespaces.
  // Important for type stripping.
  "erasableSyntaxOnly": true, // TS 5.8+

  //----- Helps with emitting .d.ts -----
  // - Forbids inferred return types of exported functions etc.
  // - Only allowed if `declaration` or `composite` are true
  "isolatedDeclarations": true,

  //----- tsc doesn’t emit any files, only type-checks -----
  "noEmit": true,
}

For more information on these settings, see the blog post “A checklist for your tsconfig.json.

package.json  

Some settings in package.json also affect TypeScript. We’ll look at those next. Related material:

Using .js for ESM modules  

By default, .js files are interpreted as CommonJS modules. The following setting lets us use that filename extension for ESM modules:

"type": "module",

Which files should be uploaded to the npm registry?  

We have to specify which files should be uploaded to the npm registry. While there is also the .npmignore file, explicitly listing what’s included is safer. That is done via the package.json property "files":

"files": [
  "package.json",
  "README.md",
  "LICENSE",

  "src/**/*.ts",
  "dist/**/*.js",
  "dist/**/*.js.map",
  "dist/**/*.d.ts",
  "dist/**/*.d.ts.map",

  "!src/**/*_test.ts",
  "!dist/**/*_test.js",
  "!dist/**/*_test.js.map",
  "!dist/**/*_test.d.ts",
  "!dist/**/*_test.d.ts.map"
],

In .gitignore, we have ignored directory dist/ because it contains information that can be generated automatically. However, here it is explicitly included because most of its contents have to be in the npm package.

Patterns that start with exclamation marks (!) define which files to exclude. In this case, we exclude the tests:

  • Some of them sit next to modules in src/.
  • The remaining tests are located in test/ – which was not even included.

Package exports  

If we want a package to support old code, there are several package.json properties, we have to take into consideration:

  • "main": previously used by Node.js
  • "module": previously used by bundlers
  • "types": previously used by TypeScript
  • "typesVersions": previously used by TypeScript

In contrast, for modern code, we only need:

"exports": {
  // Package exports go here
},

Before we get into details, there are two questions we have to consider:

  • Is our package only going to be imported via a bare import or is it going to support subpath imports?
    import {someFunc} from 'my-package'; // bare import
    import {someFunc} from 'my-package/sub/path'; // subpath import
    
  • If we export subpaths: Are they going to have filename extensions or not?

Tips for answering the latter question:

  • The extensionless style has a long tradition. That hasn’t changed much with ESM, even though it requires filename extensions for local imports.

  • Downside of the extensionless style (quoting the Node.js documentation): “With import maps now providing a standard for package resolution in browsers and other JavaScript runtimes, using the extensionless style can result in bloated import map definitions. Explicit file extensions can avoid this issue by enabling the import map to utilize a packages folder mapping to map multiple subpaths where possible instead of a separate map entry per package subpath export. This also mirrors the requirement of using the full specifier path in relative and absolute import specifiers.”

This is how I currently decide:

  • Most of my packages don’t have any subpaths at all.
  • If the package is a collection of modules, I export them with extensions.
  • If the modules are more like different versions of the package (think synchronous vs. asynchronous) then I export them without extensions.

However, I don’t have strong preferences and may change my mind in the future.

Specifying package exports  

// Bare export
".": "./dist/src/main.js",

// Subpaths with extensions
"./util/errors.js": "./dist/src/util/errors.js", // single file
"./util/*": "./dist/src/util/*", // subtree

// Extensionless subpaths
"./util/errors": "./dist/src/util/errors.js", // single file
"./util/*": "./dist/src/util/*.js", // subtree

Notes:

  • If there aren’t many modules then multiple single-file entries are more self-explanatory than one subtree entry.
  • By default, .d.ts files must sit next to .js files. But that can be changed via the types import condition.

For more information on this topic, see section “Package exports: controlling what other packages see” in “Exploring JavaScript”.

Package imports  

Node’s package imports are also supported by TypeScript. They let us define aliases for paths. Those aliases have the benefit that they start at the top level of the package. This is an example:

"imports": {
  "#root/*": "./*"
},

We can use this package import as follows:

import pkg from '#root/package.json' with { type: 'json' };
console.log(pkg.version);

For that to work, we have to enable resolution for JSON modules:

"compilerOptions": {
  "resolveJsonModule": true,
}

Package imports are expecially helpful when the JavaScript output files are more deeply nested than the TypeScript input files (as is the case for our example and for @rauschma/helpers). In that case we can’t use relative paths to access files at the top level.

Package scripts  

Package scripts lets us define aliases such as build for shell commands and execute them via npm run build. We can get a list of those aliases via npm run (without a script name).

These are commands I find useful for my library projects:

"scripts": {
  "\n========== Building ==========": "",
  "build": "npm run clean && tsc",
  "watch": "tsc --watch",
  "clean": "shx rm -rf ./dist/*",
  "\n========== Testing ==========": "",
  "test": "mocha --enable-source-maps --ui qunit",
  "testall": "mocha --enable-source-maps --ui qunit \"./dist/**/*_test.js\"",
  "\n========== Publishing ==========": "",
  "publishd": "npm publish --dry-run",
  "prepublishOnly": "npm run build"
},

Explanations:

  • build: I clear directory dist/ before each build. Why? When renaming TypeScript files, the old output files are not deleted. That is especially problematic with test files and regularly bites me. Whenever that happens, I can fix things via npm run build.
  • test, testall:
    • --enable-source-maps enables source map support in Node.js and therefore accurate line numbers in stack traces.
    • The test runner Mocha supports several testing styles. I prefer --ui qunit (example).
  • publishd: We publish an npm package via npm publish. npm run publishd invokes the “dry run” version of that command that doesn’t make any changes but provides helpful feedback – e.g., it shows which files are going to be part of the package.
  • prepublishOnly: Before npm publish uploads files to the npm registry, it invokes this script. By building before publishing, we ensure that no stale files and no old files are uploaded.

Why the named separators? The make the output of npm run easier to read.

If a package contains "bin" scripts then the following package script is useful (invoked from build, after tsc):

"chmod": "shx chmod u+x ./dist/src/markcheck.js",

Generating documentation  

I’m using TypeDoc to convert JSDoc comments to API documentation:

"scripts": {
  "\n========== TypeDoc ==========": "",
  "api": "shx rm -rf docs/api/ && typedoc --out docs/api/ --readme none --entryPoints src --entryPointStrategy expand --exclude '**/*_test.ts'",
},

As a complementary measure, I serve GitHub pages from docs/:

  • File in repository: my-package/docs/api/index.html
  • File online (user robin): https://robin.github.io/my-package/api/index.html

You can check out the API docs for @rauschma/helpers online (warning: still underdocumented).

Development dependencies  

Even if a package of mine has no normal dependencies, it tends to have the following development dependencies:

"devDependencies": {
  "@types/mocha": "^10.0.6",
  "@types/node": "^20.12.12",
  "mocha": "^10.4.0",
  "shx": "^0.3.4",
  "typedoc": "^0.27.6"
},

Explanations:

  • @types/node: In unit tests, I’m using node:assert for assertions such as assert.deepEqual(). This dependency provides types for that and other Node modules.

  • shx: provides cross-platform versions of Unix shell commands. I’m often using:

    shx rm -rf
    shx chmod u+x
    

I also install the following two command line tools locally inside my projects so that they are guaranteed to be there. The neat thing about npm run is that it adds locally installed commands to the shell path – which means that they can be used in package scripts as if they were installed globally.

  • mocha and @types/mocha: I still prefer Mocha’s API and CLI user experience but Node’s built-in test runner has become an interesting alternative.
  • typedoc: I’m using TypeDoc to generate API documentation.

Tools  

Linting npm packages  

General package linting:

  • publint: “Lints npm packages to ensure the widest compatibility across environments, such as Vite, Webpack, Rollup, Node.js, etc.”
  • npm-package-json-lint: “Configurable linter for package.json files”
  • installed-check: “Verifies that installed modules comply with the requirements [the Node.js "engines" version range] specified in package.json.”
  • Knip: “Finds and fixes unused files, dependencies and exports.”

Linting TypeScript types:

  • arethetypeswrong: “This project attempts to analyze npm package contents for issues with their TypeScript types, particularly ESM-related module resolution issues.”

These are slowly becoming less relevant because more packages use ESM and requiring ESM from CommonJS (“require(esm)”) works reasonably well in Node.js now:

Further reading  

Also useful: