Publishing ESM and CommonJS packages with Typescript
Charles Loder
Posted on March 25, 2024
ECMAScript modules (esm) is the official way to handle imports and exports in Javascript, but CommonJS (cjs) has been part of the ecosystem do so long that support for it still feels necessary.
In this post I'll show my method for publishing an npm package that exports both esm and cjs compatible code.
The code is available on stackblitz.
Note: it is better to view the code on stackblitz as the embedding shows some errors that aren't real.
src
For example purposes, the src/
directory is more complicated than it needs to be. There is an index.ts
file, which exports modules from the arithmetic/
directory.
tools
Besides Typescript, this process also relies on tsc-alias
and @alcalzone/esm2cjs
.
The first is used to add .js
extensions for imports, which isn't necessary in all contexts, but doesn't hurt.
The second compiles esm projects to cjs.
So right away, you can see there is quite a lot happening in the build process — tsc, tsc-alias, and esm2cjs.
This isn't ideal for large projects, but for small projects the build time is minimal.
configs
This is the minimal tsconfig config needed:
{
"compilerOptions": {
"declaration": true,
"declarationDir": "dist/types",
"esModuleInterop": true,
"lib": ["ES2021"],
"module": "ESNext",
"moduleResolution": "Node",
"outDir": "dist/esm",
"target": "ES2021"
},
"tsc-alias": {
"resolveFullPaths": true,
"verbose": true
},
"include": ["src"],
"exclude": ["node_modules", "test"]
}
Note the config for tsc-alias
.
package.json
The most complicated part is the package.json
:
{
"name": "tsc-as-cjs-esm",
"version": "0.1.0",
"main": "./dist/cjs/index.js",
"module": "./dist/esm/index.js",
"types": "./dist/types/index.d.ts",
"files": ["dist"],
"exports": {
"./package.json": "./package.json",
".": {
"types": "./dist/types/index.d.ts",
"require": "./dist/cjs/index.js",
"import": "./dist/esm/index.js"
}
},
"scripts": {
"start": "tsc --watch",
"build": "tsc && tsc-alias -p tsconfig.json",
"postbuild": "esm2cjs --in dist/esm --out dist/cjs -l error"
},
"devDependencies": {
"@alcalzone/esm2cjs": "^1.1.2",
"tsc-alias": "^1.8.8",
"typescript": "^5.2.2"
}
}
The exports
block lets node know where to look when importing or requiring a module.
building
You can build the package by running npm run build
. This causes:
- Typescript to compile the project
- tsc-alias to add file extensions
- esm2cjs to create a cjs package from the above output
This creates a dist/
directory that looks like this:
dist
├── cjs
├── esm
└── types
Inside esm/
and cjs/
each, there is a package.json
.
// dist/esm/package.json
{
"type": "module"
}
and
// dist/cjs/package.json
{
"type": "commonjs"
}
After that, running npm pack
creates a tarball and copies the root package.json
to the package.
testing
Create a package that uses esm modules with a package.json:
{
"name": "test",
"main": "index.js",
"type": "module"
}
Note the "type": "module"
,
Move the tarball into the repo (not necessary) and install the it using npm i tsc-as-cjs-esm-0.1.0.tgz
.
Then in index.js
, you can do:
import { add } from "tsc-as-cjs-esm";
console.log(add(1, 2));
If you remove the "type": "module"
line, you'll get an error and have to use cjs syntax:
const math = require("tsc-as-cjs-esm");
const add = math.add;
console.log(add(1, 2));
Posted on March 25, 2024
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.