Publish TypeScript packages the right way
Published on Thursday, December 22, 2022
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.
# Clone the repositorygit clone https://github.com/davidmyersdev/typescript-example.git# Navigate into the project foldercd 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
.
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.
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.
- The package provides exports for both CommonJS and ES modules
- 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.
{ "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
.
{ // 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.
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
.
export declare const greeting: (name: string) => string