DEV Community

Charles Loder
Charles Loder

Posted on

Publishing ESM and CommonJS packages with Typescript

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"]
}
Enter fullscreen mode Exit fullscreen mode

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"
  }
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Inside esm/ and cjs/ each, there is a package.json.

// dist/esm/package.json
{
    "type": "module"
}
Enter fullscreen mode Exit fullscreen mode

and

// dist/cjs/package.json 
{
    "type": "commonjs"
}
Enter fullscreen mode Exit fullscreen mode

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"
}
Enter fullscreen mode Exit fullscreen mode

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));
Enter fullscreen mode Exit fullscreen mode

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));
Enter fullscreen mode Exit fullscreen mode

Top comments (0)