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
| Approach | When it fits | What it costs you | When to pick something else |
|---|---|---|---|
| A single hand-written .mjs file with no build step | One small function whose source already runs in every target | You write the declaration file by hand, and only a type check compares it with the code | The source is TypeScript, or the utility grows past a file or two |
| dnt to generate a Node package from Deno source | The code lives in a Deno repository and npm is the second registry | A build that runs Deno, shims Deno globals for Node, and emits output you then inspect | You do not otherwise use Deno |
| Dual ESM and CommonJS built with tsup or unbuild | CommonJS callers on Node versions without require(esm) must load you | Two artifacts and two declaration files, and two module instances when both load in one process | Your Node floor already loads ES modules through require |
| ESM only with package.json exports conditions | A Node floor of 20.19 or 22.12, and TypeScript or bundler consumers | CommonJS callers on older Node versions cannot require you at all | Callers 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.
Related how-tos
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
-
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
-
The tool’s resolution runs on its own copy of TypeScript. The published manifest of
@arethetypeswrong/core0.18.5 pins it at5.6.1-rc, a release candidate from before TypeScript learned that Node canrequirean ES module. The checker is therefore exactly as strict as the compiler it ships with. ↩︎ Back to text -
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