Skip to content

Conflicting behavior with exports and "type": "module" #58057

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
Danielku15 opened this issue Apr 27, 2025 · 15 comments
Open

Conflicting behavior with exports and "type": "module" #58057

Danielku15 opened this issue Apr 27, 2025 · 15 comments

Comments

@Danielku15
Copy link

Danielku15 commented Apr 27, 2025

Version

v22.14.0

Platform

Microsoft Windows NT 10.0.26100.0
x64

Subsystem

internal/modules/cjs/loader

What steps will reproduce the bug?

  1. Create a package which declares explicit exports with the respective loaders to be used. The type is set to module and the file extensions for require exports are js.
packages/pkg1/package.json
{
    "name": "pkg1",
    "version": "1.0.0",
    "private": true,
    "type": "module",
    "exports": {
        ".": {
            "require": "./main.js",
            "import": "./main.mjs"
        },
        "./module": {
            "require": "./module.js",
            "import": "./module.mjs"
        }
    }
}
  1. In the .js files use commonjs code (require)
packages/pkg1/main.js
const util = require('node:util');
module.exports = function() {
    console.log(util.styleText('green', 'Hello from pkg1/main (esm)'));
}
  1. Consume the package in a CJS project
packages/consumer/package.json
{
    "name": "consumer",
    "version": "1.0.0",
    "private": true,
    "scripts": {
        "test": "node test.js"
    },
    "dependencies": {
        "pkg1": "^1.0.0"
    }
}
packages/consumer/test.js
const pkg1 = require('pkg1');
const pkg1Module = require('pkg1/module');

pkg1();
pkg1Module();
  1. Run the test.js in node and get presented an error:
PS D:\Dev\node-cjs-loading\packages\consumer> npm run test

> consumer@1.0.0 test
> node test.js

file:///D:/Dev/node-cjs-loading/packages/pkg1/main.js:1
const util = require('node:util');
             ^

ReferenceError: require is not defined
    at file:///D:/Dev/node-cjs-loading/packages/pkg1/main.js:1:14
    at ModuleJobSync.runSync (node:internal/modules/esm/module_job:395:35)
    at ModuleLoader.importSyncForRequire (node:internal/modules/esm/loader:360:47)
    at loadESMFromCJS (node:internal/modules/cjs/loader:1385:24)
    at Module._compile (node:internal/modules/cjs/loader:1536:5)
    at Object..js (node:internal/modules/cjs/loader:1706:10)
    at Module.load (node:internal/modules/cjs/loader:1289:32)
    at Function._load (node:internal/modules/cjs/loader:1108:12)
    at TracingChannel.traceSync (node:diagnostics_channel:322:14)
    at wrapModuleLoad (node:internal/modules/cjs/loader:220:24)

Node.js v22.14.0
npm ERR! Lifecycle script `test` failed with error: 
npm ERR! Error: command failed 
npm ERR!   in workspace: consumer@1.0.0 
npm ERR!   at location: D:\Dev\node-cjs-loading\packages\consumer 

How often does it reproduce? Is there a required condition?

Reproduces always.

What is the expected behavior? Why is that the expected behavior?

I would expect that node loads the main.js with the commonjs loader as it is and ignore the fact that .js maps to ESM from a "type: module" spec.

IMO the conditions of the conditional exports, need to also control which loader is used for the file. Its not just defining which file to use when resolving a module from CJS or ESM context.

What do you see instead?

Node.js only uses the exports for looking up the filename but then does a fallback to the default extension based loading. Due to type: module it treats it as ESM and wrongly loads it.

Additional information

I know my setup is a bit special so I want to share some insights why I have it like this, and why changing is tricky.

In reality my files are not "commonjs" but rather Universal Module Definition (UMD) files. My library is also used in classical browser script tag setups. This is for backwards compatibility with the current major version.

Changing to .cjs is a workaround that could work. But its has these impacts:

a) I considering the rename a breaking change for my library and I don't want to bump my major version just for this sake.
b) It could impact CDNs and Web Servers which might not have the extension registered as text/javascript and browsers triggering warning/errors on load due to wrong mimetypes. (e.g. jsdelivr serves it as application/node, or IIS 10 in doesn't have it either)
c) Its not the "correct" file extension for browser usage.

Another workaround is to remove type: module. But within my git repository I want to use primarily ESM (e.g. for scripts or configuration files). And this becomes problematic when using typescript configuration files. Some tools do not support something like a .mts breaking a variety of things.


I've looked through the following docs and generally I'd expect a .js to be loaded with the CJS loader if its declared accordingly in the exports section.

https://nodejs.org/api/packages.html#dual-commonjses-module-packages
https://nodejs.org/api/packages.html#conditional-exports
https://nodejs.org/api/packages.html#exports

Looking at the code I can also see that Node just resolves the plain path, but then ignores the "loader hint".

  1. File path resolve: https://github.com/nodejs/node/blob/main/lib/internal/modules/cjs/loader.js#L753
  2. Module load:
    https://github.com/nodejs/node/blob/main/lib/internal/modules/cjs/loader.js#L1286
  3. .js always using type from the package json: https://github.com/nodejs/node/blob/main/lib/internal/modules/cjs/loader.js#L1887

Node should remember the loader type from the conditional export and load the file accordingly, not use to some alternative/automatic loading behaviors than specified. That's why I report this behavior rather as a bug than an improvement request.

@ljharb
Copy link
Member

ljharb commented Apr 28, 2025

Why would you expect that? Type module means, every js file in that package scope is treated as ESM.

@Danielku15
Copy link
Author

I would expect it because I am declaring explicitly in the conditional exports that this particular export should be used for commonjs (require). IMO the conditional exports and type are conflicting in their design. In my thinking:

I am using conditional exports to clearly indicate what files to be used for commonjs or ESM. This way I avoid that any cross loading needs to happen and mitigate problems like the described "dual package hazard". There is no other way to indicate how files should be loaded than this feature. There is not much value in having the conditional exports feature if the respective loading types are not respected. One value is that can have different paths/filenames, but why would you do that if type anyhow overrules. If Node decides to use the ESM loader because of type, it should also use the exports > import file.

The problem roots in the fact that most modern tooling relies on the package.json definitions to control also the behavior when working on your own project. I want to use ESM when developing in my project but I still publish a UMD (indirectly CJS) flavor of my package. I use now a prepack script to rewrite the package.json to match what I publish, but this is merely a hack/around.

@ljharb
Copy link
Member

ljharb commented Apr 28, 2025

You can require ESM now. The exports field does not say anything about the format of the file it's pointing to - only about the mechanism being used to access it.

@Danielku15
Copy link
Author

You can require ESM now.

But the problem is that ESM cannot further require things. And also other globals from CJS are not available.
Hence this cross loading mindset is flawed and the current implementation (for this scenario) is rather prone to errors than solving any real world problems.

Package authors create CJS and ESM flavors of their files and declare them accordingly. IMO it is unrealistic that one would specify a ESM file in the require condition, especially when also specifying an import flavor right beside it. Why would I have two ESM flavors of the same module, one to be loaded via require and one via import.

I am in a CJS execution scope, so require is used. The package I need tells me which file to load for the use with require. But then the file is loaded with the ESM loader because a global setting is used assuming a switch to ESM. There is no way to explicitly control on individual exports what loader should be used and therefore its doomed to fail.

The exports field does not say anything about the format of the file it's pointing to - only about the mechanism being used to access it.

Thats one standpoint to see it, and it's flawed if you think of module graphs. The current behavior causes a reduced matrix of possible import/require scenarios which will successfully work. And the prioritization of transformed loading vs. same loader increases the risk of the "dual package hazard".

I did a quick search and I think quite an amount of issues like these could have been avoided:

If Node.js really wants to be strict on "works like intended" for the current behavior: I strongly vote for a change of the intended behavior to match closer the real world needs due to given reasons. Package authors need a better way of controlling the load behavior for modules/exports than only using the file extension .cjs or .mjs. The current behavior is misleading and prone to errors.

@ponochovny
Copy link

Yeah. changing
node_modules\@storybook-vue\nuxt\preset.js

module.exports = require('./dist/preset.cjs');

to

export * from './dist/preset.mjs';

fixes the error ReferenceError: module is not defined at file:///<path>/node_modules/@storybook-vue/nuxt/preset.js:1:1

@ljharb
Copy link
Member

ljharb commented Apr 29, 2025

If you avoid type module entirely, things are much simpler - .js for CJS and .mjs for ESM. I'd personally recommend that approach.

@Danielku15
Copy link
Author

If you avoid type module entirely, things are much simpler - .js for CJS and .mjs for ESM. I'd personally recommend that approach.

This is not easily possible as a variety of tooling nowadays relies on the central configuration in the package.json whether to use ESM or CJS when working within the project. Also Node itself uses the setting when executing scripts within your project. While until some extend you could maybe change file extensions, this problem can cascade into VS Code where even extensions pick up settings from package.json files.

If there would be different settings for the local development workspace and built+published NPM package it would be likely less problematic.

I'd say projects nowadays want to use mostly ESM when working within the development environment hence we specify type: module. But for compatibility with people still using CJS (or for browser scenarios UMD) is additional flavors are shipped.

@ljharb
Copy link
Member

ljharb commented Apr 30, 2025

What tooling? You don't need the "type" field whatsoever to use ESM - the only thing it does is change what ".js" means.

@Danielku15
Copy link
Author

The following list should give an insight of the impact of changing type in your project.

  1. Any tooling which is launched in a Node.js execution context is affected by the behavior that it would change from ESM to CJS if you remove the type field.
    • NPM Scripts
    • Testing Frameworks like Mocha, Jest
    • Bundlers like WebPack
  2. tsx is impacted on the loading mode which cascades into the executed code.
  3. esbuild is impacted as they use the setting in various places. This cascades into dependant tooling like Vite (e.g. the config loading and local plugins you might develop).
  4. biome is impacted as they use the setting in various places.
  5. Next.js Config loading (they do not support alternative extensions)

If the recommendation is: Remove type: module. This means recommending to go back to the quasi-discouraged CJS (discouraged because the JS ecosystem moves more and more to ESM and CJS support on libraries is declining). Developers are then required to:

a) Rewrite the code to use CJS features only (no top-level-await, __dirname/__filename,...)
b) Change the file extensions within the whole codebase to mjs/mts (not supported by all tooling - e.g. Next.js)
c) Requesting across the tooling landscape to handle file formats accordingly.

@ljharb
Copy link
Member

ljharb commented Apr 30, 2025

next.js doesn’t support mjs, the standard file extension for ESM? That’s very surprising to me.

@joyeecheung
Copy link
Member

joyeecheung commented May 5, 2025

From what I can tell from the problem description, a possible solution is not changing the current interpretation of exports - trying to always interpret require-d file as CommonJS would not be correct because ESM can also be required, and others can argue it's surprising behavior that their export const foo = 1 does not work when being pointed by a require condition too, then by changing it we are just breaking a bunch of users by favoring some people's intuition over others. But we can provide a new export condition like "module-sync" that denotes the module format a specific file is in, regardless of whether it's require-ed or import-ed, so could be provided as:

        ".": {
            "commonjs": "./main.js",
            "default": "./main.mjs"
        },
        "./module": {
            "commonjs": "./module.js",
            "default": "./module.mjs"
        }

(I am somewhat puzzled by why the package is prioritizing CommonJS to use the .js suffix, if CDN is a concern, and .js should be reserved for the format you care more about, shouldn't that be used for ESM instead? It's technically caring more about delivering CommonJS/UMD faster in the browser via .js than delivering ESM faster in the browser via .js, which seems odd).

Although on the other hand I think the conditional exports in the OP is still not quite idiomatic as explained in #52174 - using "which loader is loading this module" to differentiate what files to provide can already incur problems because that assumes to much about the loader, which the package doesn't get to control, the more optimal differantiation is "in which format this module can support being loaded", that's something the package can actually control.

@joyeecheung
Copy link
Member

joyeecheung commented May 5, 2025

Also from the issue statement

Another workaround is to remove type: module. But within my git repository I want to use primarily ESM (e.g. for scripts or configuration files). And this becomes problematic when using typescript configuration files. Some tools do not support something like a .mts breaking a variety of things.

It sounds like another possible solution could be moving the scripts and configurations to a different directory governed by a different package.json, or if the packages are already in separate folders, put a new package.json in those folders without the type field - the key is to avoid mixing the package.json meant for the build tools with the package.json meant for the build product - actual packages being published and distributed.

@Danielku15
Copy link
Author

Sorry for the delayed reply. In short: I'd be fine with a new dedicated conditional export which allows me to specify that a file should be loaded via the CJS or ESM loader.

The approach would be good because other tools would not receive any change in the existing behavior they have to adopt, but rather would need to add support for the new condition.

(I am somewhat puzzled by why the package is prioritizing CommonJS to use the .js suffix, if CDN is a concern, and .js should be reserved for the format you care more about, shouldn't that be used for ESM instead? It's technically caring more about delivering CommonJS/UMD faster in the browser via .js than delivering ESM faster in the browser via .js, which seems odd).

If I use .js for ESM, I am left with no proper extension for UMD. .cjs is incorrect and problematic as pointed out previously. My primary concern on CDNs is the "ease of use" by allowing devs to use my library through jsdelivr, unpkg, cdnjs etc. The NPM package is simply re-exposed and devs can opt for either the UMD or ESM flavor based on their needs.

Although on the other hand I think the conditional exports in the OP is still not quite idiomatic as explained in #52174 - using "which loader is loading this module" to differentiate what files to provide can already incur problems because that assumes to much about the loader, which the package doesn't get to control, the more optimal differantiation is "in which format this module can support being loaded", that's something the package can actually control.

I partially agree. At the same time: As a package author I ship my code in multiple flavors so that it can be consumed in compatible environments. e.g. UMD, AMD, CJS, globalThis, ESM. In a happy world, every flavor should stay in its own world. But realistically there is a need for cross-loading scenarios in module graphs making a loaders life harder.

I'd say it is less prone to errors if packages can advertise: "a.esm.js is a ESM file using ESM syntax (export, import) and b.cjs.js is a CommonJS file (require, module.export)". And then the loader picks files according to compatibility and current context. Cross-loading is done as a fallback mechanism.

My thinking might be too naive though and maybe there are many edge cases I'm not considering. The whole type and extension logic feels hacky.

It sounds like another possible solution could be moving the scripts and configurations to a different directory governed by a different package.json, or if the packages are already in separate folders, put a new package.json in those folders without the type field - the key is to avoid mixing the package.json meant for the build tools with the package.json meant for the build product - actual packages being published and distributed.

It certainly solves some aspects of individual scripts. Unfortunately the approach is only solving things partially.
In my case I'd need to go for type: commonjs in my library code to fit my desired file extensions. This change has following invasive impacts:

  • I run my unit tests running with Mocha+TSX. I need to change from tsx/esm to tsx/cjs. Otherwise I run into require() errors.
  • I get errors in my Vite configurations where I use import.meta.url. I have to rewrite them to CJS code again (__dirname) or changing the file extensions. For next.js using ESM seems a dead-end when using typescript configs (no .mts supported, I'd have to try what happens exactly on their transpile).
  • I have Vite Integration Tests where I use the Build API. But the CommonJS variant has been deprecated. So I have to rewrite those code areas to be able to load and use their APIs.

For my packages I'll try to reduce the commonjs/umd support to a minimum on the next major release to reduce the risk of having problems. I also have a workaround for me (prepack npm script). Hence, I'm fine if Node doesn't really change anything.

Looking at the ecosystem and others might make this still a relevant topic. Likely that's just the tip of the iceberg and problems are multiplying across the ecosystem. Every project affected receives bug reports and then somehow builds workarounds.

@ljharb
Copy link
Member

ljharb commented May 16, 2025

@Danielku15

If I use .js for ESM, I am left with no proper extension for UMD

this isn't accurate; you can have the root use type module, and make a subfolder with a package json that has nothing in it but { "type": "commonjs" }, and then .js files in that folder will be CJS. (ofc imo you should use .mjs for all ESM and .js for all CJS, and no "type" field at all)

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

No branches or pull requests

4 participants