If a module is going to exist as a CommonJS module, then it should only exist as a CommonJS module - there is little benefit but much complexity in also including an ES2015 version that should exhibit identical behavior, as the version used at runtime should be opaque.
If a module wishes to take advantage of features only in the ES2015 module format, then it should exist only as an ES2015 module, and not also ship a CommonJS version that has different behavior when loaded by "pre-ES2015 module support" Node.js runtimes. This could cause code to break without a single change other than upgrading the Node.js version used.
Thus if there is little benefit in shipping both module formats with identical behavior, and it is dangerous to ship both module formats with different behavior, you should never ship both module formats side-by-side, either of which may be used depending on the runtime.
Developing applications or packages containing only one version of any one module results in simplified authoring & consumption, consistent package behavior, smaller packages, and a clearer migration story.
The current proposals to add ES2015 module support to the Node.js runtime - both
the .mjs
extension approach the Node team is currently leaning towards, as well as
the "In defense of .js" proposal by Dave and Yehuda - propose that packages contain
both the CommonJS version of the modules in the package, as well as the ES2015
versions of the modules. This is to provide for interop across module types as the
ecosystem transitions. I believe this approach is suboptimal for several reasons.
Under the proposals, when a module is required from a dual-mode packages, a newer "ES2015 capable" Node.js engine would load the ES2015 format modules, and older Node.js engines would load the CommonJS format modules. This requires that both formats be kept in sync, else the consumer of the module, without any changes in its code, will see different behavior from the consumed module depending on the Node.js version being used - i.e. the exact same package versions may have a breaking change in their interoperability just by moving to a newer Node.js runtime.
If you need to ship identically behaving versions of the modules, what is the value in shiping both? If you had to ship the CommonJS versions anyway, why not only ship those, and reduce the size of your package? With the many subtle (and many not-so-subtle) differences in semantics between CommonJS and ES2015 modules, you also incur the risk of having to track down very nuanced bugs where the behavior differs across module types if shipping both, but avoid this risk if just shipping one format.
If you wanted to take advantage of features only available to ES2015 format modules, (bearing in mind you can use all other ES2015 features in CommonJS format modules), you definitely shouldn't ship such changes side-by-side with CommonJS versions that can't behave identically. Ship an ES2015-only package with the different behavior.
Another argument for the side-by-side existance of CommonJS and ES2015 modules in a package is to allow gradual adoption, with a future envisioned where all packages contain ES2015 modules, and CommonJS is "legacy". I'd argue that this approach results in holding back ES2015 modules, because as outlined above, if a package contains both and they need to behave identically, there is no ability to adopt new "ES2015 only" features in the package. If you are restricted to a "CommonJS" subset of functionality, and need to ship the CommonJS modules anyway, and all runtimes can use them... what is the incentive to ship ES2015 modules in Node.js packages? Whereas if the ES2015 modules are in a separate package, they can be revved independendly and take advantage of unique features.
Also, if packages contain both, then how can package publishers determine what number of consumers are loading the CommonJS vs ES2015 format modules from within their package? How are they to know when it's safe to deprecate the CommonJS modules from within the package? The download stats for packages containing both formats are of no help.
Shipping dual-mode packages adds complexity to publishing. This means you now
need to maintain two compatible versions of the modules - either by hand or by
including a transpiler into your build process - and restrict yourself to code
that can run with faithful semantics once transpiled. Authoring ES2015 versions
of CommonJS modules is also a challenge, for example with the proposed hoisting
of module.exports
onto the default
member, a CommonJS export is available
via two paths in ES2015 consumers, (mod.prop
, and mod.default.prop
). This
duplication would need to be created and maintained in the ES2015 format module
also, else consumer code may break (again, with no change in their code, just
depending on the Node.js version loading the module).
This is a challenge that has resulted in many discussions with regards to TypeScript and the descriptions of existing libraries in type definitions. This applies somewhat to API documentation in general however. This is closely related to the prior point, as it only occurs when a module may be either of two formats.
If the API descriptions for module lib
state it exports a function called foo
,
is that a CommonJS export or an ES2015 export? If it's a CommonJS export then
it can be consumed via the below, if it's an ES2015 export then it can't.
import lib from "lib";
lib.foo();
The above makes use of the hoisting of the exports onto the default
member,
which is preferable when consuming existing CommonJS modules, as often a function
is assigned to module.exports
, (and an imported namespace isn't callable).
I propose that there be no dual-mode Node.js modules. Specifically:
- A package/app should contain only one file for each module within it. (Note: These can still be mixed, with module "./a" being CommonJS, and module "./b" being ES2015), but the source file that represents a module should never be "it depends...".
- A package containing ES2015 modules should set the
engines
field inpackage.json
to require a version of Node.js that understands ES2015 format packages (e.g."engines": {"node": ">= 8.0.0"}
). - Consumers that wish to support runtimes that support only CommonJS modules by definition cannot depend on any ES2015 format modules, and should depend only on the CommonJS version of packages, (and obviously be a CommonJS package themselves).
This has the following benefits:
- Packages don't have to maintain two sets of modules with different semantics that try to behave identically (via transpilation or other means).
- Packages are half the size due to the above.
- Identical code won't risk subtle differences in module semantics depending on the Node.js runtime loading its dependencies.
- API descriptions and type definitions are concrete (as they model a specific version of a package/module - which is only one of CommonJS or ES2015).
- Authors can tell on NPM the metrics and dependencies for the different versions of their packages (e.g. the CommonJS only, or the ES2015 versions), and make an informed decision on when (if ever) to stop maintaining the CommonJS version.
- Authors are free to use the latest and greatest ES2015 features in their ES2015 modules, without being restricted to being compatible with the side-by-side CommonJS versions.
See the algorithms.md file in the repository for how Node.js would determine which format a module is, and how interop between CommonJS and ES2015 modules would work.