HomepageExploring TypeScript (TS 5.8 Edition)
You can support this book: buy it or donate
(Ad, please don’t block.)

9 Publishing npm packages with TypeScript

In this chapter, we’ll create an ESM-based library package for npm via TypeScript:

Icon “GitHub”Example package: @rauschma/helpers

This package uses the setup described in this chapter.

9.1 File system layout

Our npm package has the following file system layout:

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

Comments:

9.1.1 .gitignore

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

node_modules
dist
.DS_Store

Why these entries?

9.1.2 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, it’s useful if they are easy to find.

9.1.2.1 Tip for tests: self-reference the package

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

// src/misc/errors.ts
import {helperFunc} from 'my-package/misc/errors.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:

9.2 tsconfig.json

“Summary: Assemble your tsconfig.json by answering four questions” (§8.12) helps us with creating a tsconfig.json file by asking us four questions. Let’s answer these questions for our npm package:

9.2.1 Where does the output go?

tsconfig.json:

{
  "include": ["src/**/*"],
  "compilerOptions": {
    "rootDir": "src",
    "outDir": "dist",
    // ···
  }
}

Consequences of these settings:

What if we want src/ and test/ to sit next to each other? See “Putting src/ and test/ next to each other” (§8.4.1.1) for more information.

9.2.2 Output

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

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

Purposes of these files:

9.3 package.json

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

9.3.1 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",

9.3.2 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/",
  "!src/**/*_test.ts",

  "!dist/test/",
  "!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:

9.3.3 Package exports

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

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:

Tips for answering the latter question:

This is how I currently decide:

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

9.3.3.1 Specifying package exports
// Bare export
".": "./dist/main.js",

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

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

Notes:

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

9.3.4 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);

Package imports are especially helpful when the JavaScript output files are more deeply nested than the TypeScript input files. In that case we can’t use relative paths to access files at the top level.

For more information on package imports, see the Node.js documentation.

9.3.5 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:

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

9.3.6 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:

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.

9.3.7 Bin scripts: shell commands written in JavaScript

A package.json can contain the property "bin" which sets up executables. Check out my project TSConfigurator for a full example. That project has the following property in package.json:

"bin": {
  "tsconfigurator": "./dist/tsconfigurator.js"
},

We can constrain the Node.js versions with which the bin scripts can be used:

"engines": {
  "node": ">=23.6.0"
},

The following package script is useful (invoked from "build", after "tsc"):

"scripts": {
  ···
  "chmod": "shx chmod u+x ./dist/tsconfigurator.js"
}

If a package provides an executable and not a library, we don’t need to emit .d.ts files. If we use type stripping for .js, we may not need .js.map files either.

9.4 Linting npm packages

Linting the public interfaces of packages:

Linting npm packages internally:

9.5 Further reading

Also useful: