Modules: Conditional exports naming usability discussion

Created on 5 Dec 2019  Â·  61Comments  Â·  Source: nodejs/modules

Further to discussions from today re resolver stability and trying to incorporate the current feedback on conditional exports I've posted the PR at https://github.com/nodejs/node/pull/30799 to open discussion around conditional exports naming and behaviours.

From the PR description -

A major priority for the modules implementation is resolver stability, and the dual mode story through conditional exports is a big remaining piece of this.

Common usability feedback out of various discussions on conditional exports so far has been that the "default" field may be seen to be a confusing name, and that it isn't clear when the "require" condition will match either.

To try and improve the overall usability this PR makes the following condition name changes:

  • Add a new "import" condition as the converse of the "require" condition, only applying for the ESM loader.

All conditions (except for "default") remain behind the --experimental-conditional-exports flag.

This makes the dual mode workflow look like:

{
  "type": "module",
  "main": "./index.cjs",
  "exports": {
    "require": "./index.cjs",
    "import": "./index.js"
  }
}

instead of the previous:

{
  "type": "module",
  "main": "./index.cjs",
  "exports": {
    "require": "./index.cjs",
    "default": "./index.js"
  }
}

the UX improvement being that the former seems like it will look more natural to most users unfamiliar with "exports".

discussion features interoperability modules-agenda pkg-exports

Most helpful comment

I'd agree with @jkrems here, it gets more complicated if we use the term commonjs. For example, a commonjs file could use import() but it wouldn't use the commonjs condition. I'm more neutral on default/module/import though. I think part of this confusion might be the default not defining which system is being used on a glance and it will just take some repetition to learn, perhaps naming it import would have matched require more, but i doubt we will be adding other systems of loading code anytime in the foreseeable future so default seems fine to keep for me.

All 61 comments

Removing "default" will break the current implementation in node 13 for the packages I've published with "exports" - I hope we don't do that.

I like this direction. "module" and "commonjs" make far more sense to me. It’s immediately obvious what each one is for.

Furthermore, "module" is a better fallback/“for every runtime” key than "default". Every runtime that supports "exports" will support ESM, so they will all support "module", so we might as well make that the fallback key; and it’s better than "default" because "default" isn’t necessarily ESM whereas "module" is. That’s a big benefit.

We should remove "default" immediately, perhaps in the next minor release, to minimize the number of people impacted by the change. The feature has an experimental warning, so we can change or remove it at any time, and there’s no guarantee of backward compatibility.

That we can technically get away with it doesn't mean that downstream users won't suffer as a result; this isn't imposing a cost on module authors, it's imposing a cost on their consumers.

Could we add and prefer "module" and "commonjs", but still support "default" until the next major?

We also need to add to the docs some information about how conditions are recursive. For example:

{
  "type": "module",
  "main": "./index.cjs",
  "exports": {
    "node": {
      "commonjs": "./index.cjs"
    },
    "module": "./index.js"
  }
}

This formulation should cause all Node consumers to load the CommonJS index.cjs; it’s as if the ESM version wasn’t shipped at all. But all other runtimes (browsers, Deno, etc.) would load the "module" key’s index.js. This is a way to avoid the dual package hazard, as there’s exactly one version of the package available for use in Node (in either ESM or CommonJS environments). This isn’t as good a solution as the ESM wrapper approach, as the latter provides named exports, but for a package like request that provides only a root export this works just as well.

Rename the "default" condition to "module" with it only applying for the ESM resolver.

Let's please phrase this as: Remove the default condition and add a module condition. The default condition was sugar for a clean array fallback. If there's any restrictions on "module", it's fundamentally a different condition (which isn't bad, just something I think should be made explicit). So the correct way to rewrite this:

{
  "type": "module",
  "main": "./index.cjs",
  "exports": {
    "require": "./index.cjs",
    "default": "./index.js"
  }
}

Would be to use the non-default-using:

{
  "type": "module",
  "main": "./index.cjs",
  "exports": [{
    "require": "./index.cjs",
  }, "./index.js"]
}

There's other uses of default (e.g. "node" or "browser" vs. "default") where replacing default with module doesn't really make sense:

{
  "type": "module",
  "main": "./use-inline-crypto.cjs",
  "exports": {
    "node": "./use-node-crypto.cjs",
    "browser": "./use-web-crypto.cjs",
    "default": "./use-inline-crypto.cjs"
  }
}

There's no ES modules involved, so replacing "default" with "module" here would just break the package. But the following works perfectly fine:

{
  "type": "module",
  "main": "./use-inline-crypto.cjs",
  "exports": [{
    "node": "./use-node-crypto.cjs",
    "browser": "./use-web-crypto.cjs",
  }, "./use-inline-crypto.cjs"]
}

I'm a fan of the array fallbacks, so I can live with getting rid of "default". And I agree that adding a "module" condition makes sense, especially since we shipped without a "require" guard in 13 which limits what packages can do.

This proposed scheme is confusing to me as commonjs is a format which can be loaded by node.js import() but the commonjs export is only used for require(). The package.json#type field using commonjs and module to reflect the format adds to this confusion for me. I'd much prefer for commonjs / module within exports to indicate format rather than supported loader.

I'd much prefer for commonjs / module within exports to indicate format rather than supported loader.

The confusing thing about the whole distinction is that we'll realistically not make this about the format. It will always be about the supported loader. E.g. require/commonjs guards can point to custom require hooks (e.g. .coffee) or native modules (.node) which isn't a CommonJS module but "something that refers to the require loader". Same with module: It's not ESM only. It's "things the import loader can load" which may include WASM for example. At least I wouldn't expect us to make up precedent rules between different file formats within the import loader. "Pick WASM first, then ESM" sounds super weird.

I'd agree with @jkrems here, it gets more complicated if we use the term commonjs. For example, a commonjs file could use import() but it wouldn't use the commonjs condition. I'm more neutral on default/module/import though. I think part of this confusion might be the default not defining which system is being used on a glance and it will just take some repetition to learn, perhaps naming it import would have matched require more, but i doubt we will be adding other systems of loading code anytime in the foreseeable future so default seems fine to keep for me.

I'd much prefer for commonjs / module within exports to indicate format rather than supported loader.

The confusing thing about the whole distinction is that we'll realistically not make this about the format. It will always be about the supported loader.

Well that was what I liked about commonjs / module: that (I thought) it described the files in the package, not the loader to use. In general a package.json feels like it should be metadata about the package. Hence commonjs makes sense to me as “this is the CommonJS file for this path.” Even stuff like browser makes sense as I read it as “this is the browser-environment file for this path.”

So if the conditions describe the target files, and I remember the array syntax this time, my example above could be better written as:

{
  "type": "module",
  "main": "./index.cjs",
  "exports": [{
    "commonjs": "./index.cjs"
  }, {
    "module": "./index.js"
  }]
}

In this case, both Node loaders will load index.cjs, as both support "commonjs"-type files and that’s defined first in the array, making it top priority.

If the conditions instead describe the loader/method of importation, then I would call them require and import to make that connection clear. But then in order to achieve the same desired result (both Node loaders get CommonJS, other runtimes get ESM) you’d have to write as:

  "exports": {
    "node": {
      "require": "./index.cjs",
      "import": "./index.cjs"
    },
    "import": "./index.js"
  }

This feels counterintuitive to me, like it’s configuration for Node rather than metadata describing the package. Wouldn’t this also potentially introduce issues if the capabilities of loaders change over time?

Would “modern” and “legacy” work better?

Basically the two names here refer to the new loader and the old loader and
that’s it. The selection has nothing to do with the module format really.
This change is about usability though, and since the loaders are _the
commonjs loader_ and _the esm loader_, loosening the name meanings in the
name of usability for Node.js users was the goal.

Semantically, “default” and “import” and “require” are the perfectly
correct names. The problem is usability - does this work for our users.

On Thu, Dec 5, 2019 at 12:53 Geoffrey Booth notifications@github.com
wrote:

I'd much prefer for commonjs / module within exports to indicate format
rather than supported loader.

The confusing thing about the whole distinction is that we'll
realistically not make this about the format. It will always be about the
supported loader.

Well that was what I liked about commonjs / module: that (I thought) it
described the files in the package, not the loader to use. In general a
package.json feels like it should be metadata about the package. Hence
commonjs makes sense to me as “this is the CommonJS file for this path.”
Even stuff like browser makes sense as I read it as “this is the
browser-environment file for this path.”

So if the conditions describe the target files, and I remember the array
syntax this time, my example above
https://github.com/nodejs/modules/issues/452#issuecomment-561992122
could be better written as:

{

"type": "module",

"main": "./index.cjs",

"exports": [{

"commonjs": "./index.cjs"

}, {

"module": "./index.js"

}]

}

In this case, both Node loaders will load index.cjs, as both support
"commonjs"-type files and that’s defined first in the array, making it
top priority.

If the conditions instead describe the loader/method of importation, then
I would call them require and import to make that connection clear. But
then in order to achieve the same desired result (both Node loaders get
CommonJS, other runtimes get ESM) you’d have to write as:

"exports": {

"node": {

  "require": "./index.cjs",

  "import": "./index.cjs"

},

"import": "./index.js"

}

This feels counterintuitive to me, like it’s configuration for Node rather
than metadata describing the package. Wouldn’t this also potentially
introduce issues if the capabilities of loaders change over time?

—
You are receiving this because you authored the thread.
Reply to this email directly, view it on GitHub
https://github.com/nodejs/modules/issues/452?email_source=notifications&email_token=AAESFSWNDTNLDXB53V42UYLQXE5ZPA5CNFSM4JVTMXZKYY3PNVWWK3TUL52HS4DFVREXG43VMVBW63LNMVXHJKTDN5WW2ZLOORPWSZGOEGBRXAY#issuecomment-562240387,
or unsubscribe
https://github.com/notifications/unsubscribe-auth/AAESFSX4TUWUTET2VMYIZW3QXE5ZPANCNFSM4JVTMXZA
.

Wouldn’t this also potentially introduce issues if the capabilities of loaders change over time?

I do not believe this would cause issues, but loaders could potentially read/provide flags when delegating.

I prefer commonjs/module over require/default and modern/legacy. It's specific about the module format and applicable beyond just Node.

How about module and nomodule, like browsers do?

I realize I'm coming at this from the perspective of someone who ships mostly client-side code these days, but I'm personally planning on shipping UMD bundles for the libraries I maintain instead of strict CommonJS. People who use modules get ESM, nomodule (UMD) for everyone else.

Also, this may be out of scope but one thing that is sorely missing but is already being implemented in bundlers like webpack is the idea of dev vs. prod builds. Would love, love, love to see some discussion around this.

I'm probably missing something here - what's the reason this isn't sufficient to achieve dual-mode?

{
  "type": "module",
  "main": "./index.cjs",
  "exports": {
    "module": "./index.js"
  }
}

Originally, based on discussions with various Node folks, my assumption was that the following would work for shipping ES5+CJS and EScurrent+MJS:

{
  "main": "index.cjs",
  "exports": {
    ".": "index.js"
  }
}

It seems there is some confusion. The flags currently are specifying data for the system loading a package, the flags are not set due to the right hand side of the export mapping (the dependency) nor are the flags set due to the format/context of call site of the load operation (the dependent).

@developit exports shadows main. It also isn't an issue of only 1 / 2 loaders, we need to be able to support many types of entries.

So an idea... why don't we support node, commonjs, module, default (resolved in that order).

this allows for a variety of entry points and resolution with fall back, does not break existing shipped implementation (default).

I'm not 100% we should be supporting the recursive syntax, I don't think we do currently but could be mistaken. @GeoffreyBooth are you passionate about that syntax? It seems like it could significantly complicate writing maps as well as parsing them.

@guybedford I'm not in favor of "legacy"; CJS is not legacy, it's just one of two module systems.

It's clear that the exports object should describe two things:

  1. Environment (runtime)
  2. Loader (furnished by the runtime)

Node has two builtin loaders, namely "CJS" and "ESM" as can be seen in lib/internal/modules. I would explicitly use their internal names as keys, rather than "legacy", "current", etc.

{
  "exports": {
    "node": { 
      "cjs": "./index.cjs",
      "esm": "./index.mjs"
    },
    "browser": {
    }
  }
}

The rationale is that there may be a possible future where the builtin ESM loader is superseded by another loader (e.g. "ESM2", etc.). This would result in multiple "legacy" loaders existing.

@DerekNonGeneric I disagree and I think expanding this in exports significantly complicates things. I don't think we need to specify the difference between the environment and the loader here. I think we can fairly succinctly cover almost tall cases that node requires with the 4 keys I suggested above. Other environments are able to then extend with whatever keys they want. I would very much like us to avoid the recursive case

There’s some confusion here. There are really two decisions at play here:

  1. Do the condition names represent _loaders_ or the _format to be loaded_ (which may not map one-to-one with loaders).

  2. Depending on what decision is made in 1, which names make the most sense?

I think there’s probably consensus that if the condition names represent the format of the files, then the names commonjs and module make the most sense. After all, "type" already describes the format of the files, and those are the names we chose.

_However_ if the condition names represent names of _loaders_, then commonjs and module are confusing choices because those terms are already associated with describing formats of files (again, see "type").

And currently, the condition names _do_ represent the names of loaders. That’s probably why require was chosen in the first place.

I think it’s safe to say that there’s a lot of confusion about this point. I certainly was confused; I thought that we weren’t just renaming things but also making the conditions be about the _files_ rather than about the _loaders_ (as in the conditions are metadata, not configuration).

So before we get into what the best condition names are, _can_ the conditions be describing the formats of the files rather than configuring each loader what it should load? That seems to be the preference of several of the folks on this thread, or at least it’s the assumption many people seem to be making about how this works.

So before we get into what the best condition names are, can the conditions be describing the formats of the files rather than configuring each loader what it should load?

I do not believe so. Code like the following seem to be impossible to reconcile with that idea:

// foo.cjs
require('bar'); // sets the "require" condition
import('bar'); // does not set the "require" condition

So before we get into what the best condition names are, can the conditions be describing the formats of the files rather than configuring each loader what it should load?

I do not believe so. Code like the following seem to be impossible to reconcile with that idea:

// foo.cjs
require('bar'); // sets the "require" condition
import('bar'); // does not set the "require" condition

I disagree that this is a problem, I think it's a major justification of conditional exports. Consider if bar has named exports provided through an ESM wrapper. It is expected that in this example require('bar') will load the commonjs format and import('bar') should prefer to load the ESM formatted file.

@coreyfarrell that is allowed, I don't understand the disagreement. The problem with interpreting the conditions as formats is multiple.

  • import('bar') as it stands could load a different non-ESM file, anything that import can load in fact, such as .cjs files. It does not state any preference or constraint on the right hand side of an exports key value pair to enforce it isn't JSON/WASM/CJS/etc. Its usage within CJS does not make it set conditions as if it were a require('bar').
  • require('bar') as it stands does not place restrictions on the dependency, nor on where its usage is; you can use require within ESM and it won't set conditions as if it were an import('bar').

Neither of these relate to the dependent or dependency formats. I do not believe that the conditions can actually be tied to the format of a dependency nor of the dependent given our current designs and doing so seems impossible since things like import and require potentially resolving to different dependencies by design.

Here my 2 cents/opinions:

Like: 2 opposite conditions for Loader, e. g. module/commonjs or import/require
Rational: Make it easier to extend and allows users to be more specific

Dislike: module/commonjs
Like: import/require
Rational: commonjs is a little bit off because it would probably also affect UMD and AMD formats. import/require reflects the semantics of the condition: "the way a package is consumed". require still seems to be a bit off when using UMD or AMD format, but I think it's acceptable (AMD also uses require as name.

Dislike: Having a list of condition ordered and match in this fixed order: node, commonjs, module, default
Like: Having a Set of equal conditions matched in order of the properties in exports.
Rational: One is able to read the exports property from top-to-bottom to figure out which entrypoint is used without having to look up the precedence list. Having a precedence list doesn't really make sense to be when having orthogonal conditions. This gets more important when more orthogonal conditions are added (like dev/prod or es2019/es2020).
More info: An object with multiple keys would translate into an array of objects with a single key. A default key must always be the last one. { node: "./a", import: "./b", prod: "./c", default: "./d" } would be semantically equal to [ {node: "./a"}, {import: "./b"}, {prod: "./c"}, "./d" ]. Especially when allowing recursive conditions a precedence list is really confusing when using orthogonal conditions in a single object.

Like: development/production as condition.
Rational: Would eliminate hacks like if (process.env.NODE_ENV === 'production') { module.exports = require('./cjs/react.production.min.js'); } else { module.exports = require('./cjs/react.development.js'); } in react. Such hacks are not possible with ESM so a condition would help to archive the same behavior.

Like: webassembly and top-level-await as conditions
Rational: importing webassembly or using top level await would be hard errors when used and it's not supported.

Idea: else instead of default
Rational: default could be confused with the ESM default export. e. g. could mean a condition that is matched when the default exports is imported. Not proposing to add this, just saying it could be confusing. (no strong opinion

Dislike: versions like node>=13 as condition
Rational: We should check features instead of versions. The same mistake has been made with browser version checking instead of feature check in the past.

@sokra I don't think anyone pitched node>=13 - I've mentioned that condition elsewhere, but I was referring to using support for export maps themselves as a way to target node 13 (since that's where they are first supported).

Regarding default VS else - maybe fallback?

Agreed regarding the usefulness of development/production. It seems like we'd want all tooling to support these, but the standard to allow any value. As an example, it might also be useful to have a test env, which wouldn't directly map to development or production.

@bmeck just to clarify my view is that the node.js ESM loader should prefer the import condition but be able to fallback to the require condition. My starting position is that the import condition should be for files that are in a format supported by the standard ECMAScript module loader.

@coreyfarrell

the import condition should be for files that are in a format supported by the standard ECMAScript module loader.

For ESM is no format that isn't supported since ESM is an abstract interface and not a specific format, in fact the design was always to load any format that can produce an Abstract Module Record; CJS can do this (and Node contains loading mechanisms to do so) but not with the same interface as things like Babel which do not enforce conformance to that.

my view is that the node.js ESM loader should prefer the import condition but be able to fallback to the require condition

I'm not sure i understand this "fallback" bit, I think you are talking about:

{
  "name": "foo",
  "exports": {
    ".": {"require": "./foo.cjs"}
  }
}

When loaded via import("foo") it would load ./foo.cjs rather than failing to find a module. Can you clarify why this is desired; I can imagine some people not wanting to have their packages used via ESM for various reasons such as modules that manipulate require.cache so it would be good to understand what is gained by this behavior.

I think that if we’re keeping this feature as having the conditions describing loaders rather than the formats of the files to be loaded, we should use the names require and import. Then this is at least somewhat straightforward to explain. So for the simplest example:

{
  "type": "module",
  "main": "./index.cjs",
  "exports": {
    "require": "./index.cjs",
    "import": "./index.js"
  }
}

I would explain in the docs like this:

This package’s index.cjs file is returned when this package is loaded via require, whether in a require('pkg') call in a CommonJS environment or a require('pkg') call in an ES module environment (via createRequire) or in other environments like UMD that support loading modules via require.

This package’s index.js file is returned when this package is loaded via import, whether in an import 'pkg' statement in an ES module environment or an import('pkg') call in an ES module or CommonJS environment.

I think this UX would work for most users. I _wouldn’t_ use commonjs or module here because we’re using those terms to describe formats of files in "type" and this conditional exports API very much _isn’t_ describing formats of files, at least as currently designed.

I think there’s still an open question as to whether the condition being the “method of import” (require or import) rather than the format of the file to be imported (commonjs or module) is actually _better,_ but my primary concern was usability so as long as the API is straightforward enough that most users aren’t confused by it, I think either way is fine.

my view is that the node.js ESM loader should prefer the import condition but be able to fallback to the require condition

I'm not sure i understand this "fallback" bit, I think you are talking about:

{
  "name": "foo",
  "exports": {
    ".": {"require": "./foo.cjs"}
  }
}

When loaded via import("foo") it would load ./foo.cjs rather than failing to find a module. Can you clarify why this is desired; I can imagine some people not wanting to have their packages used via ESM for various reasons such as modules that manipulate require.cache so it would be good to understand what is gained by this behavior.

@bmeck That is what I'm talking about. I can see your point so probably import() should fallback to default, not support fallback to the require condition.

I stand corrected on the idea that ESM declares a standard format but in practice other platforms cannot import() a CJS file. I'm thinking about how tooling which generates import maps for browsers picks the appropriate conditions, building a list of sources that need transpile to ESM. Some of this can be done by checking extension of the target to determine the source format but that adds difficulty. Having the import condition refer to CJS files seems to me like it is a node.js specific feature.

Edit: Initially I suggested import() should fallback on node or default but node is already higher priority so default would be the only fallback.

Having the import condition refer to CJS files seems to me like it is a node.js specific feature.

To do it right, you’d need to do it like this:

  "exports": {
    "node": {
      "require": "./index.cjs",
      "import": "./index.cjs"
    },
    "import": "./index.js"
  }

That way _Node’s_ import gets index.cjs, but any other runtime’s import gets index.js. But yes, this is a potential source of bugs.

@GeoffreyBooth I think realistically, you would just write the following:

  "exports": {
    "node": "./index.cjs",
    "import": "./index.mjs"
  }

Which will always give node the .cjs and other import-supporting runtimes the .mjs. I can't think of a really meaningful scenario where you'd write out both require and import and point them to the same file.

I've updated the PR at https://github.com/nodejs/node/pull/30799 to support "require"/ "import" as this discussion seems to have converged on.

The remaining points seem to be as far as I can tell:

  1. Do we want a "node" condition?
  2. Will we remove "default", deprecate or leave it around? If we keep it around do we still want to consider another name?
  3. Should we implement "development" / "production" conditions?
  4. Are we happy with ordering being by condition priority set by the resolver, or do we want to reconsider object order?
  5. Are we happy with greedy nesting semantics, or should we consider having eg "exports": { "node": { "never-matches": "./never-used.js" }, "default": "./fallback.js" } always try the "fallback" if the "node" condition doesn't have any valid sub matches. (currently the example throws not found instead of using fallback).

Perhaps we can split off some of the above into their own issues further.

Do we want a "node" condition?

Yes, we need it in order to have both Node loaders get CommonJS, while other runtimes get ES modules. A package author might want both Node loaders to get CommonJS in order to avoid the dual package hazard without needing to write an ESM wrapper.

Will we remove "default", deprecate or leave it around? If we keep it around do we still want to consider another name?

My vote would be remove it ASAP while it’s still only a few weeks old and it hasn’t gotten adoption. We can always bring it back later.

Should we implement "development" / "production" conditions?

By implement you mean, have Node acknowledge and do something with? Does Node core already do something with NODE_ENV=production? If so, we should support whatever NODE_ENV supports.

I assume unrecognized strings are just ignored, so if someone wants other environments like staging and performance they can just add whatever strings they want?

Are we happy with ordering being by condition priority set by the resolver, or do we want to reconsider object order?

We already have the array syntax for people who want to define explicit order, so we can have the resolver do whatever it wants as an intelligent default that authors can override via array syntax.

Are we happy with greedy nesting semantics, or should we consider having eg "exports": { "node": { "never-matches": "./never-used.js" }, "default": "./fallback.js" } always try the "fallback" if the "node" condition doesn't have any valid sub matches. (currently the example throws not found instead of using fallback).

I think if there’s a node condition, Node should only look inside there. Authors can always put a fallback inside there: "node": [{"never-matches": "./index.js"}, "./fallback.js"].

Should we implement "development" / "production" conditions?

By implement you mean, have Node acknowledge and do something with? Does Node core already do something with NODE_ENV=production? If so, we should support whatever NODE_ENV supports.

git grep -l NODE_ENV on the node.js repository only lists files within deps/npm and tools/node_modules. I don't remember where but someone had mentioned to me that node itself does nothing with NODE_ENV and they desired to keep it that way. Sorry I can't find the thread to reference.

Are we happy with greedy nesting semantics, or should we consider having eg "exports": { "node": { "never-matches": "./never-used.js" }, "default": "./fallback.js" } always try the "fallback" if the "node" condition doesn't have any valid sub matches. (currently the example throws not found instead of using fallback).

I think my vote is for greedy semantics. If people want to back out, they can use arrays. To immediately raise a counter-point: Part of me would like the syntax to cleanly desugar into arrays of single-key object chains - which implies non-greedy. E.g.:

// This:
{ "node": { "never-matches": "./never-used.js" }, "default": "./fallback.js" }
// Would desugar to:
[{ "node": { "never-matches": "./never-used.js" } }, // node && never-matches -> never-used.js
 { "default": "./fallback.js" }]                     // true -> fallback.js

With greedy matching, there's no simpler representation but the complex nested structure.

To immediately raise a counter-point: Part of me would like the syntax to cleanly desugar into arrays of single-key object chains - which implies non-greedy. E.g.:

// This:
{ "node": { "never-matches": "./never-used.js" }, "default": "./fallback.js" }
// Would desugar to:
[{ "node": { "never-matches": "./never-used.js" } }, // node && never-matches -> never-used.js
 { "default": "./fallback.js" }]                     // true -> fallback.js

With greedy matching, there's no simpler representation but the complex nested structure.

That's was exactly what is was talking about.

// This:
{
  "import": "./module.js",
  "node": {
    "never-matches": "./never-used.js"
  },
  "default": "./fallback.js"
}

// I would read it like:
if (import) return "./module.js";
if (node) {
  if (never-matches) return "./never-used.js";
}
return "./fallback.js";

// But actually this currently behaves like:
if (node) {
  if (never-matches) return "./never-used.js";
  return false;
}
if (import) return "./module.js";
return "./fallback.js";

In my opinion this feels counter-intuitive. I think this should be changed.

It's not about that user don't have the freedom (as you could actually use the array syntax to specify it this way), but more about how users think that would behave and also about object syntax is less verbose.

so we can have the resolver do whatever it wants as an intelligent default that authors can override via array syntax.

I would prefer it to avoid this kind of magic.

Do we think commonjs/module conditions in addition to require/import would be valuable? As clarified earlier, require/import are conditions for the loader, based on the import site, not the module format. The problem is, import can refer to either a CommonJS (only via dynamic import though?) or ESM module, whereas require can only refer to a CommonJS module. This means we cannot infer module format based on the import condition alone - you'd have to either set a type field or use .mjs in addition.

commonjs/module conditions could make it easier to ship dual format packages. I think these conditions are easier for package authors to understand since they're based on characteristics of the target file not where it's loaded from. I'm not sure how useful require/import really is, since it doesn't actually have anything to do with the file being imported.

For the implementation, I think the cjs loader would only look for the commonjs condition, and the ESM loader would look for the module and commonjs (only for dynamic import) conditions in that order.

I'm not sure how useful require/import really is, since it doesn't actually have anything to do with the file being imported.

You could replace those terms with "sync"/"async". require is "this file can be loaded from a synchronous loading system", import is "this file can be loaded from an asynchronous loading system". So it does say a great deal about the file being imported.

I don't think that file format is valuable to express in the exports map. The exports map doesn't apply to relative imports. So the metadata about file format would disappear for imports within the package, creating a confusing situation imo. type is a much more reliable way to declare how .js should be interpreted which is orthogonal to resolution in different environments.

I've like to move forward with landing https://github.com/nodejs/node/pull/30799 on master (adding an import condition behind the conditional exports flag). Is everyone ok with that here, provided we get the necessary core approvals?

No, I am not OK with renaming "default", it will break packages out in the wild on node v13, and it will prevent backporting conditional exports to v12 for the same reason.

I'm just here to say that i like "default" for the simple reason that...

{
  "exports": {
    "require": "./index.cjs",
    "default": "./index.js"
  }
}

"require" and "default" have the same number of characters.

No, I am not OK with renaming "default", it will break packages out in the wild on node v13, and it will prevent backporting conditional exports to v12 for the same reason.

I think the current PR is adding import as a new condition and deemphasizes default in the docs without removing support for it.

I don't think that file format is valuable to express in the exports map.

Disagree. People have been doing this for years with main/module before exports existed and will be looking for a replacement. I don't think type is intuitive in this regard: it applies to all exports, not just main, so it's unclear how one should make a package that supports both ESM and CommonJS without changing the file extension of their code (is this even possible?). I think this will create a lot of confusion vs making the key determine the module format.

{
  "type": "module",
  "main": "foo.js", // this is ESM
  "exports": {
    ".": "bar.js" // this is also ESM
  }
}
{
  "main": "foo.js", // this is CommonJS
  "exports": {
    ".": "bar.js" // this is also CommonJS?
  }
}

Only way to make a package that supports both is like this?

{
  "main": "foo.js", // this is CommonJS
  "exports": {
    ".": "bar.mjs" // this is ESM
  }
}

This is pretty confusing IMO. Having commonjs and module conditions would be much simpler and easier to explain and document.

@devongovett Right, but your alternative means that if you import './bar.js' from within the package, it couldn't possibly know that it's supposed to interpret this file as an ES module. Also, what if one of your exports is JSON or WASM? Or if you have a file that's not directly exported but is used by both your CJS and ESM files, e.g. to share state? I don't think using exports to differentiate different kinds of .js files scales beyond very simple examples and gets super confusing afterwards.

If you don't want to change file extensions, you can always create a minimal package.json (just type) in a subdirectory where you put all your CJS. Having two .js files in the same directory next to each other but each is effectively a different file type doesn't sound like something that prevents confusion.

I see. This needs to be very well documented then. I've already seen several examples similar to the ones above, assuming that anything inside exports is ESM and main is CommonJS when this is not the case. If you want to support both CJS and ESM in the same package, then one of them cannot use .js.

if you import './bar.js' from within the package, it couldn't possibly know that it's supposed to interpret this file as an ES module.

Wait, couldn't that always be ESM since you're importing from ESM? You cannot import a CJS module, and you cannot require an ESM one. So it would just be the same as the parent module format. That's how tools supporting module have worked. I suppose import() is the odd one out here... đŸ€”

You cannot import a CJS module, and you cannot require an ESM one.

In node, you can import a CJS module. The default export of the resulting namespace will be the module.exports object.

if someone wouldn't mind clarifying or pointing me at the right doc -- if a node import map mixes CJS with ESM, what happens if i do import { thing } from 'module' when 'module' could resolve to either format? does it fail when CJS is selected & succeed when ESM is selected?

if someone wouldn't mind clarifying or pointing me at the right doc -- if a node import map mixes CJS with ESM, what happens if i do import { thing } from 'module' when 'module' could resolve to either format? does it fail when CJS is selected & succeed when ESM is selected?

I'm not 100% sure if I understand the question correctly but from what you describe: We explicitly do not support "'module' may resolve to either CJS or ESM". What we do support in conditional exports is "'module' may resolve to different targets depending on if it is loaded via import or require". So in your example, since it's an import statement, it can only ever resolve to the mapping for import. If the import mapping is set to a CJS file, it would succeed (since we allow import of CJS). If the import mapping is set to an ESM file, it would also succeed. As would any future file formats like WASM/JSON.

If the require (!) mapping is set to an ESM file (or any other file format not supported by require), an attempt to require('module') would fail. It's not really any different than the situation today where it would fail if you set main to a file format not supported by require.

If the import mapping is set to a CJS file, it would succeed (since we allow import of CJS).

Though the { thing } part wouldn’t work for CommonJS. You’d need import moduleDefault from 'module'; const { thing } = moduleDefault;.

thank you both for the clarifications. the situation is much clearer to me now 😄

https://github.com/nodejs/node/pull/30799 has landed, so now we have import and require and default. Do we want to keep default?

  • Pro: It provides a condition equivalent to “no condition,” what you’d get if you hadn’t been using conditional exports.
  • Con: Seems a bit redundant now that we have import.

Con: Seems a bit redundant now that we have import.

I'm not sure I follow this argument. How is it redundant with import?

"exports": {
  "browser": "./browser.cjs",
  "default": "./node.cjs" // this would break with the `import` condition
}

EDIT: Replace browser with development or featureset2019 or anything else that's not exactly require. :)

I'm not sure I follow this argument. How is it redundant with import?

It’s redundant because any runtime that supports or will ever support "exports" supports ESM; so there’s no need for another fallback that’s matched after import. An equivalent to your example without default would be to replace default with node, or to define both require and import to point to the same CommonJS file. Or put another way, I don’t think there are any use cases that default enables that aren’t already achievable by require and import.

So if it doesn’t provide any independent functionality, its only value is as a shorthand or alternate syntax, or for “completeness” if we feel like we need a condition to represent the “no condition” case. The question is whether these benefits are worth the cost of added API (one more thing to maintain, one more thing to learn, one more thing developers can use incorrectly, etc.).


 weighed against the cost of breakage in the wild if it's removed, and ESM is ever backported to an LTS node like v12.

An equivalent to your example without default would be to replace default with node, or to define both require and import to point to the same CommonJS file.

So you mean that default can be replaced by two fields because we assume that there's only two loading systems in JS:

"exports": {
  "browser": "./browser.cjs",
  "import": "./generic.cjs",
  "require": "./generic.cjs"
}

That seems a bit... ugly? At that point, I would hope we'd suggest the much less verbose variant which is also much less likely to be used incorrectly by accident (by forgetting a field etc):

"exports": [{
  "browser": "./browser.cjs"
}, "./generic.cjs"]

So yes - default is redundant with array fallbacks. But I don't think it has a real connection to the import condition. I don't think we can remove default without encouraging more use of string-in-array.

@jkrems the last example you gave gets reformatted by any tool which modifies package.json. The result becomes:

"exports": [
  {
    "browser": "./browser.cjs"
  },
  "./generic.cjs"
]

This is an example if why I would use default if available over the array notation. For my own packages I would only use of array notation in the edge case of needing to override default resolver priorities.

So yes - default is redundant with array fallbacks. But I don't think it has a real connection to the import condition. I don't think we can remove default without encouraging more use of string-in-array.

Let’s consider what these terms mean. As I understand them:

  • node: this file (or files, if there are conditions under node) are intended for the Node runtime
  • browser/electron/etc.: ditto for other runtimes
  • require: this file should be used by any runtime that loads via require
  • import: this file should be used by any runtime that loads via import
  • default: this file should be used by any runtime

Putting a CommonJS file in import would work for Node but for no other runtime, and so should therefore be considered an antipattern; it would by definition break anywhere but Node. CommonJS files should only be defined in require or node, never import or default.

Why not CommonJS in default? Because if default is intended for _any_ runtime, then a user really should only ever be putting ESM in there; nothing else is cross-compatible outside of Node. And if default should always be getting only ESM, then it’s really the same as import but with the possibility of a footgun that import doesn’t have.

Why not CommonJS in default? Because if default is intended for any runtime, then a user really should only ever be putting ESM in there; nothing else is cross-compatible outside of Node.

What about... JSON? What about any future file format that may be usable from require but also be widely supported by other JS runtimes? Also, and this is purely aesthetics, the following just looks... off:

"exports": {
  "react-native": "./rn-path.js", // runtime
  "electron": "./electron-path.js", // runtime
  "browser": "./browser-path.js", // runtime
  "node": "./node-path.js", // runtime
  "import": "./generic-path.js" // ... module loader?
}

"exports": {
  "production": "./prod.js", // optimization-level
  "import": "./generic-path.js" // ... module loader?
}

It reads like "if it's an apple, do X. if it's affected by gravity, do Y". It's a distracting level of detail that has nothing to do with the intent - which is "for everything that's not an apple".

Putting a CommonJS file in import would work for Node but for no other runtime.

I don't believe that's true. Bundlers have been reading a "browser" field with browser CJS code for a long time. And it's possible to run CJS in browsers (not efficiently or in production), just not with the built-in ESM loader. But that aside - there will always be packages that point something like import or default to files that do not actually work in every JS runtime. I don't think such a field would be useful. The semantics can only be "this is the broadest version I offer". If a user tries to run it, they'll find out that the file format isn't (yet) supported in their runtime. Or they'll find out that the JS syntax used is too new. Or that the file needs certain APIs not available in their runtime (e.g. global URL).

To me it's the same principle as shipping a version of your website to user-agents you don't recognize. Yes, they may fail to understand the page. But it's not on the server to prevent them from trying. So yes, if all you have is CJS, I do believe that you set default to it. Because who knows - maybe the runtime can handle it. If it doesn't, there's no harm done (and they'll have to find a different package anyhow). At least it had a chance.

Closing as resolved.

@guybedford do we remove it from minutes in #461 or keep for update(s)?

Was this page helpful?
0 / 5 - 0 ratings

Related issues

mhdawson picture mhdawson  Â·  5Comments

mhdawson picture mhdawson  Â·  4Comments

MylesBorins picture MylesBorins  Â·  5Comments

MylesBorins picture MylesBorins  Â·  4Comments

MylesBorins picture MylesBorins  Â·  4Comments