In this chapter, we’ll create an ESM-based library package for npm via TypeScript:
Example package:
@rauschma/helpers
This package uses the setup described in this chapter.
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:
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. See “Documenting TypeScript APIs via doc comments and TypeDoc” (§11).
src/
is for the TypeScript source code.
src/test/
is for integration tests – tests that span multiple modules. More on unit tests soon.
src/
and test/
next to each other? That would have the negative consequence that output files would be more deeply nested in the project directory than input files (more information).
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.
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.
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:
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:
Q: Do you want to transpile new JavaScript to older JavaScript?
Q: Should TypeScript only allow JavaScript features at the non-type level?
Q: Which filename extension do you want to use in local imports?
.js
. Now TypeScript transpilation can change .ts
to .js
, so we can use .ts
– with the benefit that the same code also runs without transpilation on some platforms (Node.js, Deno, Bun, etc.).
Q: What files should tsc emit?
.js
, .js.map
, .d.ts
, .d.ts.map
(more information on that soon)
tsconfig.json
:
{
"include": ["src/**/*"],
"compilerOptions": {
"rootDir": "src",
"outDir": "dist",
// ···
}
}
Consequences of these settings:
src/util.ts
dist/util.js
src/test/integration_test.ts
dist/test/integration_test.js
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.
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:
util.js
: JavaScript code contained in util.ts
util.js.map
: source map for the JavaScript code. It enables the following functionality when running util.js
:
util.d.ts
: types defined in util.ts
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.
package.json
Some settings in package.json
also affect TypeScript. We’ll look at those next. Related material:
Chapter “Packages: JavaScript’s units for software distribution” of “Shell scripting with Node.js” provides a comprehensive look at npm packages.
You can also take a look the the package.json
of @rauschma/helpers
.
.js
for ESM modulesBy default, .js
files are interpreted as CommonJS modules. The following setting lets us use that filename extension for ESM modules:
"type": "module",
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:
src/
.
src/test/
.
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:
However, I don’t have strong preferences and may change my mind in the future.
// 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:
.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”.
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.
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
:
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.
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.
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.
Linting the public interfaces of packages:
package.json
files”
Linting npm packages internally:
"engines"
version range] specified in package.json
.”
node_modules
, inspect dependencies, and more.”
Also useful:
package.json "exports"
” of the TypeScript Handbook