Repository navigation
require(esm) 'module.exports' interop export name support #61645
Description
🔍 Search Terms
require(esm), module.exports export, node 22.12
✅ Viability Checklist
- This wouldn't be a breaking change in existing TypeScript/JavaScript code
- This wouldn't change the runtime behavior of existing JavaScript code
- This could be implemented without emitting different JS based on the types of the expressions
- This isn't a runtime feature (e.g. library functionality, non-ECMAScript syntax with JavaScript output, new syntax sugar for JS, etc.)
- This isn't a request to add a new utility type: https://github.com/microsoft/TypeScript/wiki/No-New-Utility-Types
- This feature would agree with the rest of our Design Goals: https://github.com/Microsoft/TypeScript/wiki/TypeScript-Design-Goals
⭐ Suggestion
Node 20.19.0, 22.12.0, 23.0.0 added requiring ESM files (known as require(esm)). This feature includes module.exports interop export name feature that allows users to customize what the module.exports value would be when a module is required.
(nodejs/node#53848)
Currently TypeScript does not use the module.exports export type when requiring an ESM file.
I'd like TypeScript to use the module.exports export type for the types when that ESM file is required, aligning the type with the actual runtime object.
📃 Motivating Example
I made a reproduction: https://github.com/sapphi-red-repros/typescript-require-esm-module-exports-export
You can see how the types and the actual runtime values are different by the following steps
- Run
pnpm i - (Run
pnpm build) (the built file is already included in the repo) - Run
node index.cjs - See types in
index.cts
The concrete differences are:
testPkg['module.exports'isundefinedat runtime, but the type is{ foo: string; default: { bar: string; }; bar: string; }testPkgcontainsbar: stringat runtime, but the type does not include that
nodejs/TSC#1622 (comment) describes the motivation of this module.exports export interop feature.
💻 Use Cases
-
What do you want to use this for?
To migrate a package that had CJS files in past, to ESM-only while keeping the interface as-is without generating a separate type definition file. -
What shortcomings exist with current approaches?
It requires two type definition types, one forrequireand one forimport, so two builds are needed. The package.json would also be convoluted.mainfield cannot be used as the type definition file is different forrequireandimport. Theexportsfield would need to be like:
{
"exports": {
".": {
"types": { "require": "./index.d.cts" },
"default": "./index.js"
}
}
}- What workarounds are you using in the meantime?
I haven't considered it yet. Probably I'll use theexportsfield workaround described above.
Activity
andrewbranch commentedon May 23, 2025
This has some tricky implications when the
requirecomes from transpiled code. In a.ctsfile, you can write all of these:Each transpiles to a
require("./foo.mjs")plus some other stuff. For these to have valid semantics,require("./foo.mjs")would need to return a module namespace object (or at least a non-callable object). I think we should just issue an error on import declarations that transpile torequirethat resolve to ES modules with a"module.exports"export.sapphi-red commentedon May 24, 2025
Does this mean that users would have to use
import foo = require('foo.mjs')iffoo.mjshasmodule.exportsexport?If this behavior is adopted, how should package authors generate the JS and DTS files if they want to keep the same behavior (keeping
import foo from 'foo'work) while migrating to ESM from CJS? Should they add amodule.exportsexport in the JS file but remove it from the generated DTS file?andrewbranch commentedon Jun 12, 2025
Fixed by #61805
Upon more careful analysis, no. In general, transpiled imports just work. There are some complexities around
import *and whether you haveesModuleInteropenabled, but those rules fall out of existing rules for what imports can resolve to what exports.