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:
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.
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:
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.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.
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:
tsconfig.json
In this section, we’ll go through the highlights of tsconfig.json
. Related material:
tsconfig.json
in my blog post “A checklist for your tsconfig.json
”.
tsconfig.json
files for several use cases.tsconfig.json
of @rauschma/helpers
.{
"include": ["src/**/*", "test/**/*"],
"compilerOptions": {
// Specify explicitly (don’t derive from source file paths):
"rootDir": ".",
"outDir": "dist",
// ···
}
}
Consequences of these settings:
src/util.ts
dist/src/util.js
test/my-test_test.ts
dist/test/my-test_test.js
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
The TypeScript compiler performs three tasks:
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:
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 modules By 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.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:
src/
.test/
– which was not even included.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 TypeScriptIn 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:
import {someFunc} from 'my-package'; // bare import
import {someFunc} from 'my-package/sub/path'; // subpath import
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/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:
.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);
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 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.
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",
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/
:
my-package/docs/api/index.html
robin
): https://robin.github.io/my-package/api/index.html
You can check out the API docs for @rauschma/helpers
online (warning: still underdocumented).
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.General package linting:
package.json
files”"engines"
version range] specified in package.json
.”Linting TypeScript types:
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:
tshy - TypeScript HYbridizer: Compiles TypeScript to ESM/CommonJS hybrid packages.
ESM-CJS Interop Test: Slightly outdated but useful list of things that can go wrong when importing a CommonJS module from ESM.
tsconfig.json
: Blog post “A checklist for your tsconfig.json
”Also useful:
package.json "exports"
” of the TypeScript handbook