Publish TypeScript packages the right way

Published on Thursday, December 22, 2022

JavaScriptOSSTypeScript

I absolutely love using TypeScript for my JavaScript projects, but it does present challenges specific to its ecosystem at times. Publishing types with your packages is a great example of this, so I want to take you through the process of properly supporting multiple moduleResolution configurations.

Optional prerequisites

To help you follow along, I created a project called typescript-example. You are welcome to continue without it, but I will be referencing various snippets from it throughout this article.

bash
# Clone the repository
git clone https://github.com/davidmyersdev/typescript-example.git
# Navigate into the project folder
cd typescript-example
# Install dependencies (and build project)
npm install

Understanding the moduleResolution field in TypeScript

Module resolution is the process the compiler uses to figure out what an import refers to. (source)

In TypeScript, this process is used to determine where the types for an imported module reside. The strategy used is dependent on the configuration of the project that is consuming your package, but it attempts to mimic Node's module resolution as closely as possible. I will focus on the Node and Node16 strategies.

The Node strategy

This strategy directs TypeScript to resolve imports as CommonJS modules via the main field of the imported package. Although the name is Node, it does not support all resolution strategies that Node supports, such as the exports field. Without consideration of this difference, your types might not resolve correctly in some project configurations.

An example of this strategy is the examples/app-node package. In src/index.ts, it imports a module from a package calledlib that can be found at examples/lib.

ts
import { doTheThing } from 'lib'

The lib package defines main as ./cjs/dist/index.js, and if you hover over the 'lib' import in an editor with TypeScript intellisense, you will see that it resolves as such.

Subpath exports

In that same src/index.ts file, the next line imports a module from a subpath of the lib package.

ts
import { doSomethingElse } from 'lib/subpath'

In modern versions of Node, packages can define an exports field to specify exactly what can be imported. In packages that do not define the exports field and in versions of Node that do not support it, any resolvable path can be imported. Because the Node strategy does not recognize the exports field, all subpaths must exist as real paths in the package. For the statement above, Node will attempt to resolve the subpath as lib/subpath.js, lib/subpath/cjs/dist/index.js (appending the value of its main field), or lib/subpath/index.js. In this case, it resolves with the second option. As before, you can hover over the 'lib/subpath' import to verify.

The Node16 and NodeNext strategies

The Node16 strategy, as it sounds, brings support for the resolution features available in Node 16. Similarly, the NodeNext strategy brings support for the latest stable Node release. These features include ECMAScript (ES) modules, subpath patterns, and conditional exports, among other things. Additionally, if a package exports both ES modules and CommonJS modules, Node16 will prefer ES modules.

The example for this strategy can be found at examples/app-node-next. It also imports modules from the lib package in src/index.ts, but these modules are resolved as ES modules via the exports field in lib instead of CommonJS modules via the main field.

Building a package that works with multiple configurations

The amount of work it takes to build a universal package, one that works in most configurations, varies based on the needs of your project and your preferences as a maintainer. That said, we will work with the following assumptions in mind to cover some common scenarios.

  1. The package provides exports for both CommonJS and ES modules
  2. The package includes one or more subpaths

To help you get started, there are boilerplate packages available in the typescript-example repository under the starter-app and starter-lib directories.

Create the example-lib project

To start, create a new folder called example-lib. Inside that folder, run npm init -y to create a package.json that looks something like this.

json
{
"name": "example-lib",
"version": "1.0.0",
"description": "",
"main": "index.js",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1"
},
"keywords": [],
"author": "",
"license": "ISC"
}

Next, install TypeScript with npm install -D typescript. Then, create the following tsconfig.json.

json
{
// https://aka.ms/tsconfig
"compilerOptions": {
"declaration": true,
"declarationMap": true,
"module": "ESNext",
"moduleResolution": "Node",
"outDir": "./dist",
"skipLibCheck": true,
"sourceMap": true,
"strict": true,
"target": "ESNext"
},
"include": [
"./src/**/*"
]
}

This configuration will instruct TypeScript to compile your code to ES modules with type declarations and source maps. Create the entrypoint to your library at src/index.ts, and add the following code to it.

ts
export const greeting = (name: string) => {
const isMorning = (new Date()).getHours() < 12
if (isMorning) {
return `Good morning, ${name}`
}
return `Good day, ${name}`
}

Now, run npx tsc to compile the project to the dist folder. If things are set up correctly, you should see the following type declaration in dist/index.d.ts.

ts
export declare const greeting: (name: string) => string