Skip to main content
Publish a module to npm that supports ESM and CJS with dnt.

dnt — the easiest way to publish a hybrid npm module for ESM and CommonJS

Though browsers and JavaScript have come a long way, writing and publishing JavaScript modules is still painful. To maximize adoption, your module should support CommonJS and ESM, JavaScript with TypeScript declarations, and work in Deno, Node.js, and web browsers. To achieve that, many resort to complex release pipelines or maintaining two copies of code with slightly different module syntax.

What if you could write your module once with modern tooling like TypeScript and transform it to support all use cases?

dnt — Deno to Node transform

dnt is a build tool that transforms Deno modules into Node.js/npm-compatible packages. Not only that, the transformed package:

  • supports both CommonJS and ESM,
  • can work in Node.js, Deno, browers,
  • runs tests in both CommonJS and ESM,
  • supports TypeScript and JavaScript

How does it work? At a high level:

  • Transforms Deno code to Node.js compatible TypeScript code
    • Rewrites Deno’s extensioned module specifiers to ones compatible with Node.js module resolution.
    • Injects shims for any Deno namespaces APIs detected, as well as other globals which can be configured.
    • Rewrites remote imports from Skypack or esm.sh as bare specifier imports and adds them to the package.json as dependencies.
    • Other remote imports are downloaded and included in the package.
  • Type checks the transformed TypeScript code with tsc.
  • Writes out the package as a set of ESM, CommonJS and TypeScript type declaration files along with a package.json.
  • Runs the final output in Node.js through a test runner which supports the Deno.test() API.

You can develop and test all your code in Deno and TypeScript. When it’s time to publish, you can use dnt to export it to Node.js/npm-compatible format.

Let’s run through an example with my module, is-42. (You can also view the final source code here.)

Write, transform, publish

We’ve created a simple and totally real module that tests whether a variable is the number 42. The main logic will be in mod.ts:

// mod.ts
export function is42(num: number): boolean {
  return num === 42;
}

We’ll write some tests in mod_test.ts:

// mod_test.ts
import { assertEquals } from "https://deno.land/std@0.192.0/testing/asserts.ts";
import { is42 } from "./mod.ts";

Deno.test("42 should return true", () => {
  assertEquals(true, is42(42));
});

Deno.test("1 should return false", () => {
  assertEquals(false, is42(1));
});

We can run the tests without any additional configuration with deno test:

$ deno test
Check file:///Users/andyjiang/Developer/deno/is-42/mod_test.ts
running 2 tests from ./mod_test.ts
42 should return true ... ok (13ms)
1 should return false ... ok (7ms)

ok | 2 passed | 0 failed (142ms)

Finally, let’s also add a LICENSE and README.md file in the directory root, because it’s a real module:

a screenshot of the directory

That’s it!

Let’s transform this to an npm package by creating the build script, build_npm.ts:

import { build, emptyDir } from "https://deno.land/x/dnt@0.37.0/mod.ts";

await emptyDir("./npm");

await build({
  entryPoints: ["./mod.ts"],
  outDir: "./npm",
  shims: {
    deno: true,
  },
  package: {
    name: "is-42",
    version: Deno.args[0],
    description:
      "Boolean function that returns whether or not parameter is the number 42",
    license: "MIT",
    repository: {
      type: "git",
      url: "git+https://github.com/lambtron/is-42.git",
    },
    bugs: {
      url: "https://github.com/lambtron/is-42/issues",
    },
  },
  postBuild() {
    Deno.copyFileSync("LICENSE", "npm/LICENSE");
    Deno.copyFileSync("README.md", "npm/README.md");
  },
});

This script creates a new npm folder as the output directory, where it outputs an entire npm package from your module.

In the build() options, we set the entry file, output directory, shims, and all the context needed to build out a package.json file.

In the postBuild() function, we include filesystem operations to copy our LICENSE and README.md files accordingly.

Let’s run build_npm.ts script with the version as a parameter:

$ deno run -A build_npm.ts 0.0.1
[dnt] Transforming...
[dnt] Running npm install...

added 6 packages, and audited 7 packages in 2s

found 0 vulnerabilities
[dnt] Building project...
[dnt] Type checking ESM...
[dnt] Emitting ESM package...
[dnt] Emitting script package...
[dnt] Running post build action...
[dnt] Running tests...

> test
> node test_runner.js

Running tests in ./script/mod_test.js...

test 42 should return true ... ok
test 1 should return false ... ok

Running tests in ./esm/mod_test.js...

test 42 should return true ... ok
test 1 should return false ... ok
[dnt] Complete!

If you’re following along, your directory should have a new subdirectory npm, in it contains your transformed npm package (which supports CJS and ESM) as well as tests in both formats.

screenshot of our new npm subdirectory

Not only are tests generated for CJS and ESM, they’re also run using both Deno and Node, so you can be confident your code runs in both runtimes.

Now, publishing your CommonJS/ESM compatible npm package is as simple as:

$ npm publish /npm

Check out the published package on npm.

With dnt transforming your module to support CommonJS and ES Modules, maintaining your module is easier, as your code base is smaller.

Automate with GitHub Actions

To make it easier to publish everytime we tag a release, we can use GitHub Actions with dnt. Note that the below is an extremely simplified version, but should get you started in the right direction.

Create a .github/workflows/action.yml directory and file, which will perform the following steps anytime a new release is tagged and published:

  • check out the repo
  • parse the release version
  • setup Deno
  • run the build_npm.ts script with release version number
  • setup Node and npm with an npm auth token
  • publish with npm publish npm/
name: Publish to registry
on:
  release:
    types: [published]

jobs:
  publish_to_npm:
    name: Publish to npm
    runs-on: ubuntu-latest
    steps:
      - name: Checkout is-42
        uses: actions/checkout@v3

      - name: Set env
        run: echo "RELEASE_VERSION=${GITHUB_REF#refs/*/}" >> $GITHUB_ENV

      - name: Setup Deno
        uses: denoland/setup-deno@v1
        with:
          deno-version: v1.x

      - name: Build npm package
        run: deno run -A build_npm.ts $RELEASE_VERSION

      - name: Setup Node/npm
        uses: actions/setup-node@v3
        with:
          node-version: 18
          registry-url: 'https://registry.npmjs.org'
          scope: '@lambtron'

      - name: Publish to npm
        run: npm publish npm/ --access=public
        env:
          NODE_AUTH_TOKEN: ${{ secrets.NPM_AUTH_TOKEN }}

Note that you will need to create a Classic Token (type Automation) from npmjs.com and save that as a GitHub Actions secret as NPM_AUTH_TOKEN.

Now, everytime you publish a new tagged release, it will trigger this action and publish your module to npm.

For more details on using GitHub Actions with dnt, check out the documentation.

What’s next?

Writing software should be productive, simple, and fun. It shouldn’t also include managing complex build pipelines or intricate code bases to support the widest user base.

And while we believe ESM is the future, we recognize that many npm modules still use CommonJS. Module authors unfortunately bear the brunt of needing to support both CommonJS and ESM. So we like abstractions that make creating and publishing software simpler, such as dnt.

Are you using dnt? Let us know on Twitter or Discord.