How-to › Maintain libraries across languages

How to package one utility for Node, Deno, Bun, and browsers#

Publish one zero-dependency function so it imports cleanly in Node, Deno, Bun, and a bundler, and prove it against the packed tarball rather than the source tree.

Audience
Library maintainer
Level
intermediate
Topic
Choose and build small utilities
Languages
TypeScript and JavaScript
Verified

Your ten-line id generator breaks three ways for the people who install it. A bundler user gets Could not resolve "node:crypto", a CommonJS caller gets ERR_REQUIRE_ESM, and a TypeScript user on moduleResolution: bundler sees a signature from two releases ago. Each failure comes from how the package was published, which your tests never exercised.

What you get

You will end up with a package of one hand-written ES module, its declarations, and an ordered exports map. The captured checks import the packed tarball from Node and from an esbuild browser bundle, and the same smoke files run under Deno and Bun. This is for you if you publish small utilities.

Short answer

Write the function as one ES module that imports nothing and reads globalThis.crypto at call time. Give package.json an exports map with types first and default last, and a Node floor where require loads ES modules. Then run npm pack, check the tarball with publint and are-the-types-wrong, and import the unpacked package from Node, Deno, Bun, and an esbuild browser bundle.

You will need

Node 20.19 or later on the 20 line, or 22.12 or later, the releases where require() loads an ES module without a flag.1 Verified 2026-10-15 against Node 22.22.0, publint 0.3.25, are-the-types-wrong 0.18.5, esbuild 0.28.2 and TypeScript 6.0.3, all pinned in the sample’s package.json.

Approaches compared

ApproachWhen it fitsWhat it costs youWhen to pick something else
A single hand-written .mjs file with no build stepOne small function whose source already runs in every targetYou write the declaration file by hand, and only a type check compares it with the codeThe source is TypeScript, or the utility grows past a file or two
dnt to generate a Node package from Deno sourceThe code lives in a Deno repository and npm is the second registryA build that runs Deno, shims Deno globals for Node, and emits output you then inspectYou do not otherwise use Deno
Dual ESM and CommonJS built with tsup or unbuildCommonJS callers on Node versions without require(esm) must load youTwo artifacts and two declaration files, and two module instances when both load in one processYour Node floor already loads ES modules through require
ESM only with package.json exports conditionsA Node floor of 20.19 or 22.12, and TypeScript or bundler consumersCommonJS callers on older Node versions cannot require you at allCallers you must keep run Node 18 or an older bundler

The single file and ESM only are one choice made at two sizes: both ship ES modules alone, and the first skips the compiler. Dual output reaches CommonJS callers on old Node versions and pays for it twice, in artifacts and in the dual-package hazard measured below. dnt suits a Deno-first codebase and adds a build for everyone else.

Keep the entry free of Node

The module imports nothing. Randomness comes from the Web Cryptography API, which Node, Deno, Bun, and browsers all expose on globalThis, and the function looks it up when an id is made.

function webCryptoFill(bytes) {
  const crypto = globalThis.crypto
  if (typeof crypto?.getRandomValues !== 'function') {
    throw new RidError('globalThis.crypto.getRandomValues is not available in this runtime')
  }
  crypto.getRandomValues(bytes)
}

getRandomValues fills the array in place. Importing node:crypto instead ties the module to runtimes that provide Node’s built-ins, so a browser build stops, as smoke/node-only.mjs shows:

npx esbuild smoke/node-only.mjs --bundle --platform=browser --format=esm --outfile=out/node-only.mjs
✘ [ERROR] Could not resolve "node:crypto"

    smoke/node-only.mjs:2:31:
      2 │ import { randomFillSync } from 'node:crypto'
        ╵                                ~~~~~~~~~~~~~

Reading process.env has the same problem. A test in the sample reads rid.mjs and fails on import, require(, process, Buffer or node:, so the portability rule is checked rather than remembered.

Order the exports map

Conditions are matched in object order, first match wins. Node’s documentation says types should always come first and default last.

  "type": "module",
  "exports": {
    ".": {
      "types": "./rid.d.mts",
      "default": "./rid.mjs"
    },
    "./package.json": "./package.json"
  },
  "files": ["rid.mjs", "rid.d.mts"],
  "sideEffects": false,
  "engines": {
    "node": "^20.19.0 || >=22.12.0"
  }

There is no require condition, because require resolves through default to the ES module. The engines range is written as two ranges on purpose. A plain >=20.19 admits Node 22.0 to 22.11, which still refuse require(esm) without a flag. A dual package nests types inside each branch, import pointing at .d.mts and require at .d.cts, as variants/dual does.

Pack it and lint the tarball

Check what ships, not what sits in the directory. pack.mjs runs npm pack --json, lists the tarball, and unpacks it into smoke/node_modules the way an install would.

node pack.mjs
out/meterco-rid-1.0.0.tgz
  package.json
  rid.d.mts
  rid.mjs
npx publint out/meterco-rid-1.0.0.tgz
Running publint v0.3.25 for out/meterco-rid-1.0.0.tgz...
Unpacking tarball...
Linting...
All good!

are-the-types-wrong resolves the package the way TypeScript does under each module setting. The esm-only profile reports, without failing on, the two modes an ESM-only package does not target.

npx attw out/meterco-rid-1.0.0.tgz --profile esm-only --no-color --no-summary
┌───────────────────┬────────────────────────────────────────┬────────────────────────────┐
│                   │ "meterco-rid"                          │ "meterco-rid/package.json" │
├───────────────────┼────────────────────────────────────────┼────────────────────────────┤
│ node16 (from ESM) │ 🟢 (ESM)                               │ 🟢 (JSON)                  │
├───────────────────┼────────────────────────────────────────┼────────────────────────────┤
│ bundler           │ 🟢                                     │ 🟢 (JSON)                  │
├───────────────────┼────────────────────────────────────────┼────────────────────────────┤
│ node10            │ (ignored) 💀 Resolution failed         │ (ignored) 🟢 (JSON)        │
├───────────────────┼────────────────────────────────────────┼────────────────────────────┤
│ node16 (from CJS) │ (ignored) ⚠️ ESM (dynamic import only) │ (ignored) 🟢 (JSON)        │
└───────────────────┴────────────────────────────────────────┴────────────────────────────┘

node10 predates exports entirely. The node16 (from CJS) warning describes TypeScript’s node16 mode, which still treats require of an ES module as an error; the TypeScript 5.8 release allows it under --module nodenext.2

Check it worked

The smoke test is four lines, and each runtime must print the same id from the same fixed bytes.

import { rid } from 'meterco-rid'
const id = rid(8, (bytes) => bytes.fill(200))
if (id !== '88888888' || rid().length !== 21) throw new Error(`unexpected id ${id}`)
console.log(`import: ${id} and a random id of ${rid().length} characters`)
node smoke/smoke.mjs && node smoke/smoke.cjs
import: 88888888 and a random id of 21 characters
require: 88888888 and a random id of 21 characters

The browser build goes through esbuild with platform=browser, which turns on the browser condition and refuses to resolve a node: import. The runner has no browser, so Node runs the bundle; what the run proves is that the bundle resolves and works without Node’s built-ins.

npx esbuild smoke/smoke.mjs --bundle --platform=browser --format=esm --log-level=warning --outfile=out/smoke.browser.mjs && node out/smoke.browser.mjs
import: 88888888 and a random id of 21 characters

Deno and Bun are not on the runner that re-checks these pages, so they are not captured here. Run by hand in the Linux x64 container that verified this page, deno run smoke/smoke.mjs under Deno 2.9.6 and bun smoke/smoke.mjs under Bun 1.4.2 both printed the same import: line, and both also ran smoke.cjs. The unit tests cover the alphabet, the size limits, a runtime without the Web Cryptography API, and the portability rule.

node --test test/rid.test.mjs
1..5
# tests 5
# suites 0
# pass 5
# fail 0

When it goes wrong

TypeScript users on moduleResolution: bundler see an old signature. The package lists one types condition for both import and require, and it points at .d.cts declarations nobody updated. variants/types-first is that package.

  "exports": {
    ".": {
      "types": "./rid.d.cts",
      "import": "./rid.mjs",
      "require": "./rid.cjs"
    }
  },
node pack.mjs variants/types-first variants/dual
npx attw out/meterco-rid-stale-1.0.0.tgz --no-color --no-summary
┌───────────────────┬────────────────────────┐
│                   │ "meterco-rid-stale"    │
├───────────────────┼────────────────────────┤
│ node10            │ 💀 Resolution failed   │
├───────────────────┼────────────────────────┤
│ node16 (from CJS) │ 🟢 (CJS)               │
├───────────────────┼────────────────────────┤
│ node16 (from ESM) │ 🎭 Masquerading as CJS │
├───────────────────┼────────────────────────┤
│ bundler           │ 🟢                     │
└───────────────────┴────────────────────────┘

The bundler row is green, which is why the failure is silent for those users. TypeScript reads rid.d.cts and rejects a call the JavaScript accepts:

npx tsc -p smoke
smoke/types.ts(7,51): error TS2554: Expected 0-1 arguments, but got 2.

Nest types inside each of import and require, and generate both declaration files from the same source, or ship one.

An error from your package fails an instanceof check in the caller. In hazard.mjs the application imports the dual package, a createRequire call stands in for a CommonJS dependency that requires it, and Node loads two separate copies.

node smoke/hazard.mjs
meterco-rid-dual
  same RidError class for import and require: false
  error thrown via require is instanceof the imported RidError: false
meterco-rid
  same RidError class for import and require: true
  error thrown via require is instanceof the imported RidError: true

The ESM-only package gives one instance to both, because require(esm) returns the module that import loaded. Run by hand in the same container, Deno 2.9.6 (with --allow-read --allow-env) and Bun 1.4.2 printed the same six lines. The documentation for esbuild names the same failure in bundles the dual package hazard.3

When not to do this

Do not go ESM only while your users run Node 18 or need require on Node 22.0 to 22.11. Ship dual output until your floor moves, and keep module state and instanceof checks out of the API, so two copies cost size and nothing else. Match errors by name or a code property instead.

Do not hand-write declarations for a utility past a single file. Generate them from TypeScript source; the hand-written .d.mts here works because the type check in smoke/ compares it with a real call.

Do not add dnt to a codebase that is not already Deno-first. It earns its build step when Deno is where you work, and adds a second build system when it is not.

Do not trust the source directory. files, exports and the declaration paths only meet in the tarball, so run every check on the output of npm pack.

Last verified

Verified 2026-10-15 against Node 22.22.0 and npm 10.9.4, with publint 0.3.25, are-the-types-wrong 0.18.5, esbuild 0.28.2 and TypeScript 6.0.3. Every output block is what the preceding command printed. Deno 2.9.6 and Bun 1.4.2 were run by hand in the same Linux x64 container, outside the captured commands, and their results are described in prose.

Footnotes

  1. Node’s modules documentation keeps the whole history in a table. The feature arrived behind a flag in 22.0.0 and 20.17.0, lost the flag in 22.12.0 and 20.19.0, stopped printing its experimental warning in 22.13.0, and stopped being experimental in 25.4.0. Node 20 skipped a step: 20.19.0 lost the flag and the warning in the same release. ↩︎ Back to text

  2. The tool’s resolution runs on its own copy of TypeScript. The published manifest of @arethetypeswrong/core 0.18.5 pins it at 5.6.1-rc, a release candidate from before TypeScript learned that Node can require an ES module. The checker is therefore exactly as strict as the compiler it ships with. ↩︎ Back to text

  3. The esbuild page that names the hazard goes on to say that Node deliberately does not implement loading ESM through require. Node’s own documentation records the flag for that loading being removed in 22.12.0. Documentation about another project’s behavior ages at that project’s pace, which is the case for linking the project’s own page. ↩︎ Back to text

Read this page as markdown · All how-to guides

Generate the client instead of writing it#

Retries, timeouts, pagination and auth are the same problems in every client. Voxgig generates them from your OpenAPI description, in 23 languages, from one model.

Listen to Fireside, the Voxgig podcast

Since 2018, Richard Rodger has talked with developer advocates, event organizers, and the people who build developer-facing companies about the specifics of the work.