Skip to content

Export ESM module#1518

Open
flying-sheep wants to merge 12 commits into
microsoft:mainfrom
flying-sheep:pa/esm
Open

Export ESM module#1518
flying-sheep wants to merge 12 commits into
microsoft:mainfrom
flying-sheep:pa/esm

Conversation

@flying-sheep
Copy link
Copy Markdown
Contributor

@flying-sheep flying-sheep commented May 10, 2026

Allow bundlers to pick the best version, do tree-shaking, …

I checked that the below works with typescript, but I didn’t check if npmjs.com is smart enough to add the little “TS” icon. I assume so according to their docs, but if not, we can re-add the old "types" field.

Details about exports

According to https://www.typescriptlang.org/docs/handbook/release-notes/typescript-4-7.html#packagejson-exports-imports-and-self-referencing:

if you write an import from an ES module, it will look up the import field, and from a CommonJS module, it will look at the require field. If it finds them, it will look for a corresponding declaration file. If you need to point to a different location for your type declarations, you can add a "types" import condition.
[…]
It’s important to note that the CommonJS entrypoint and the ES module entrypoint each needs its own declaration file, even if the contents are the same between them.

and according to npmjs.com’s RFC 46:

If there is not the explicit fields: Resolving the main with both TypeScript and Flow's extra file resolvers (e.g. when distribution/danger.js look for the files ./distribution/danger.js.flow and ./distribution/danger.d.ts)

So in summary, TypeScript 4.7+ should find the .d.ts files, and npmjs.com should therefore resolve it to, but others had problems with that: browserslist/browserslist-useragent-regexp#1545

Copy link
Copy Markdown
Contributor

@StellaHuang95 StellaHuang95 left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for tackling this — the dual CJS+ESM publishing direction is the right move and will help modern bundler consumers via tree-shaking.

I built and loaded the package locally before reviewing. Left three inline comments:

  1. 🔴 ESM build isn't signaled as ESM to Node (api/package.json) — Node loads out/esm/main.js only via the MODULE_TYPELESS_PACKAGE_JSON reparse fallback. Stricter consumers (TS node16/nodenext, some bundlers) will reject it. Easiest fix: emit out/esm/package.json with {"type":"module"} during the build.
  2. 🟡 "module": "esnext" is a footgun (api/tsconfig.esm.json) — works today only because there are no relative imports. The first import './helper' a future contributor adds will compile fine and crash at runtime in Node ESM. Recommend "module": "node16" so TS enforces the .js extension.
  3. 🟡 Scope creep in .vscode/settings.json — the formatter change (and resulting package.json reformat) is unrelated to ESM publishing and changes team-wide editor behavior. Please split it out.

Two minor non-blockers: the two emitted .d.ts files are byte-identical (43 KB shipped twice — could be deduped via a single shared declaration), and the two builds run sequentially when they could run in parallel.

Happy to re-review once the above is addressed.

Comment thread api/package.json
Comment thread api/tsconfig.esm.json
{
"extends": "./tsconfig.base.json",
"compilerOptions": {
"module": "esnext",
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 "module": "esnext" is the wrong target for a package shipped to Node — it's a footgun for future contributors.

esnext emits ESM syntax but does not enforce Node's ESM resolution rules. The most important rule TypeScript skips under esnext is that relative imports must include the file extension:

// Compiles fine under "module": "esnext"
import { thing } from './helper';

// Required by real Node ESM at runtime
import { thing } from './helper.js';

Today this happens to work because main.ts has zero relative imports — the only import is 'vscode'. But the next contributor who splits a helper into a separate file and writes import { x } from './helper' will:

  • Compile cleanly ✅
  • Pass review ✅
  • Get published ✅
  • Crash at runtime in any pure-Node ESM consumer with Cannot find module './helper'

"module": "node16" (or "nodenext") makes TypeScript enforce the .js extension at compile time, catching the bug immediately. It also makes TS emit .mjs files, which — bonus — eliminates the "type": "module" issue I flagged on package.json since the extension itself signals ESM to Node.

Recommend "module": "node16" here.

Copy link
Copy Markdown
Contributor Author

@flying-sheep flying-sheep May 21, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Unfortunately that’s not true, typescript only ever creates .js files (and .d.ts files): microsoft/TypeScript#18442 (comment)

To switch to .mjs (and maybe also .cjs), we would need a rename step as described in the linked comment.

Would you prefer that rename step to adding the nested package.json? There is a dedicated tool to do the latter: https://github.com/azu/tsconfig-to-dual-package

I think I’m tending towards using that tool because of the aforementioned footgun about not being allowed to reuse the same .d.ts file.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not an expert on this, so thanks for pointing this out🙂 My understanding is there are two concerns here.

At build time: tsc doesn't enforce extensions on relative imports under module: "esnext", so it compiles fine but might break at runtime in Node's strict ESM resolver.

Switching tsconfig.esm.json to module: "nodenext" would catch this at compile time, but it also needs "type": "module" added to api/package.json so that tsc detects src/main.ts as an ESM source (otherwise nodenext would emit CJS into the ESM build).

At consume time: the .d.ts file paired with the ESM entrypoint is classified as CJS (since the nearest ancestor package.json has no "type":"module"), mismatching the .mjs file which Node treats as ESM at runtime. The fix would be either:

  • Rename .d.ts → .d.mts/.d.cts (same mve step you already use for .js), or
  • Use tsconfig-to-dual-package to add sentinel package.json files:
  1. out/esm/main.d.ts → walk up → out/esm/package.json ("type":"module")
  2. out/cjs/main.d.ts → walk up → out/cjs/package.json ("type":"commonjs")

Is this what you're thinking too? No preference on which one to pick.

Comment thread .vscode/settings.json Outdated
@flying-sheep
Copy link
Copy Markdown
Contributor Author

flying-sheep commented May 21, 2026

OK done. As said, "module": "nodenext" | "node16" does not make typescript emit .mjs or .cjs files, so I just rename the files after compiling.

I chose mve despite it being unmaintained, as it is very simple, works flawlessly, and it has much fewer dependencies than renamer or move-file-cli.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants