-
Notifications
You must be signed in to change notification settings - Fork 44
Patterns for interoperability #139
Comments
Can you expand on this. In particular in your base statements you say that CJS would provide a default export equal to import lib from 'cjs'; would have the same value for const lib = require('cjs'); addendum: I'm guessing the isomorphism you are talking about is the result of |
I will need to rephrase it: I meant that most of the libraries existing today cannot keep their API and move to ESM in a consumer agnostic way... but somehow ended-up writing something unrelated. I am mostly concerned about exploring how far you can go with the intersection between ESM and CJS. I am especially interested in the last section: providing an API that can let your lib migrate to ESM without breaking your consumers, regardless of their own module type. I posted the message too early accidentally and it's getting late in Europe: I probably made some mistakes, I'll rephrase/update some parts tomorrow. |
Ok, it ended up being a pretty long post, but it should be ready now. It covers all the patterns I see currently around the CJS/ESM interop. The goal is to find ways for library authors to migrate to ESM without breaking their consumers too much. To achieve this, there are few "patterns" that were discussed here. I compiled them, provided examples and discussed their properties. To summarize:
|
can we change from using |
|
This comment has been minimized.
This comment has been minimized.
I found a pattern for full compat without mjs+js, I hope you won't need to use it 😄 (default promise pattern / zalgo pattern). CJS lib module.exports = Promise.resolve({
default: module.exports,
foo: 42,
}); ESM lib export const foo = 42;
export const default = Promise.resolve({
default,
foo
}); CJS consumer: require("./lib").then({default} => default)
.then(lib => {
console.log(lib.foo);
}); ESM consumer import("./lib").then({default} => default)
.then(lib => {
console.log(lib.foo);
}); Any combination of lib/consumer CJS/ESM and transitions are preserved by this pattern, it does not require It allows you to have a safe migration and not break the expectation of the consumer when he migrates from CJS to ESM. |
There are a ton of things being considered in isolation here. I'm having trouble finding a good way to reply to this, but I think if you take the different approaches and judge how they are able to be used in coordination with each other rather than isolation it serves a very different picture of migration possibilities. For example, when talking about the // ./impl
module.exports = {foo: 'bar'}; // ./lib.js
module.exports = Promise.resolve(require('./impl')); // ./lib.mjs
import lib from './impl';
export var foo = lib.foo; This allows "ESM-safe migration", and allows us to preserve support for versions that do not have |
Thanks for the reply, Regarding your last message, my initial reaction was "why not using only facade then?". This is a solution with a new combination of pros/cons. I'll add it to the list. |
can we add a column to the support table on if the |
@bmeck CJS API: Shape of the result for Do you want me to add something else or are you referring to this info? The name of the column may be confusing. Do you have a better term for this? Should I separate the API for static import and dynamic import? |
@demurgos I'm trying to get a table for places it will be safe to migrate from: ...
x = require('cjs')
... to ...
x = import('module')
... Due to the return value of those being the same shape. The Shape of the CJS API right now in that table does not encompass if it is safe to move from |
Ok, I was assuming that the shape of dynamic Would this change reply to your question? Current:
New:
You can trivially replace Do you think it would be valuable to submit this post as a PR? |
Sounds good to make a PR |
I second the suggestion for this to become a PR, or a Wiki page. This is a great document. Forgive my ignorance, but can you please explain what you mean by Also are the patterns here written to cover just the various scenarios within the current |
I'll probably finish the PR over the week-end: I need to also need to add a section defining the terminology used here. Agreed regarding
Here are some CJS files and their module.exports = {
shuffle(arr) { return [...arr].sort(() => Math.random() - 0.5); }
empty(arr) { arr.length = 0; }
}
// type Exports = {shuffle: Function, empty: Function} module.exports = (a, b, r) => a*r + b*(1-r);
// type Exports = Function; module.exports = /\.*@\.*/;
// type Exports = RegExp; module.exports = [2, 3, 5, 7, 11, 13, 17];
// type Exports = number[];
export const foo = 42;
export default {foo};
// type Ns = {foo: number, default: {foo: number}}; export default function (a, b, r) { return a*r + b*(1-r); }
// type Ns = {default: Function}; Most of the patterns are based around the current |
Hi, |
Can this be closed? |
I am closing this for now since it is not really an issue but more my observations on how the different mechanism enable interop. |
Edit: This post ended up pretty long, I hope it depicts a relatively complete picture of the current state of native CJS/ESM interop.
Introduction
Hi,
Following the current discussions around defining a better term instead of "transparent interop" (#137, #138), it seems that most of it revolves around allowing libraries to migrate to ESM without impacting the consumer ("agnostic consumer"). I'd like to do a summary of the migration path I see given the current state of the discussion. I'll base it around "patterns" enabling libraries to safely migrate to ESM.
This relates to the feature Transparent migration (#105) and the use-cases 18, 20, 39, 40.
I'll leave aside the discussion around detecting the module type: commonJs (CJS) or ESM. To indicate the type of the module, I will either include
cjs
oresm
in its name or use file extensions (.js
for CJS,.mjs
for ESM). Other module types (.json
,.node
,.wasm
, etc.) are not discussed (they can be reduced to CJS or ESM).I'll use a Typescript-like notation in some places to explain the types of the values.
I expect the modules to move forward from CJS to either CJS+ESM or ESM-only and won't focus on moving backward from ESM to CJS.
I'll focus on solutions, without loaders or @jdalton's
esm
package. In the rest of this post,esm
always refer to "some module with the ES goal", not theesm
package.Here are my assumptions regarding the various ways to import the modules:
require("cjs")
continues to work unchanged.If
module.exports
has the typeExports
, it returnsExports
.import ... from "esm"
andimport("esm")
works as defined by the spec: statically import bindings or get a promise for a namespace object.If the ESM namespace has the type
Ns
,import("esm")
returnsPromise<Ns>
.import ... from "cjs"
exposes a namespace with a single export nameddefault
. It has the value ofmodule.exports
incjs.js
.import("cjs")
returns a promise for a namespace object with a single keydefault
set to the value ofmodule.exports
. It has the same behavior whether it is used from CJS or ESM.If
module.exports
has the typeExports
,import("cjs")
returnsPromise<{default: Exports}>
require("esm")
returns a promise for the namespace ofesm
. Synchronous resolution is discussed later (but from what I understood it is not likely to happen due to timing issues).If the ESM namespace has the type
Ns
, it returnsPromise<Ns>
.Today, Node only supports CJS. It means that we have a CJS lib and a CJS consumer. We want to move to ESM lib & ESM consumer. In the general case, converting both modules at the same time is not possible (for example they are in different packages). It means that the transition will go through a phase where we have either ESM lib & CJS consumer, or CJS lib & ESM consumer.
It is important to support both cases to allow libraries and consumers to transition independently. Creating a dependency between the lib and consumers transition was the main failure of the Python 2-3 transition and we want to avoid it. Python finally managed to transition by finding a shared subset between both version. Similarly, the Node transition from CJS to ESM may need to pass through an intermediate phase where an API uses the intersection between CJS and ESM to facilitate the migration.
Specifically, I am interested in the following two goals:
The first point is about enabling the migration of libs, the second about the migration of consumers.
Both are important even if this post is more intended for libs.
It helps to further break down the requirements for lib migrations:
When a consumer moves from CJS to ESM, I consider that he gets expected results if one of the following is true:
module.exports
in CJS becomes thedefault
export in ESM. It keeps the same value:module.exports
in CJS is a plain object, its properties become ESM exports. They keep the same values:The consumer expects that its migration will happen as one of the two cases above. Any other result is surprising. This is what consumers expect today:
--experimental-modules
introduced the first case, Babel and Typescript started with the second case. The actual way to migrate is left to lib documentation or consumer research. The consumer is active at this moment: we want to reduce its overhead but a small amount of work can be tolerated. Any other result when the consumer moves from CJS to ESM is surprising: we need to avoid it. Especially, we need to avoid returning values of different types when there are strong expectations that they'll be the same.From a library point of view, here is a migration path that allows a progressive process:
module.exports
cannot move to ESM-only without breaking its CJS consumers.This path emerged during the discussions as something that we would like to support. It enables to dissociate the API changes from the migration. It allows libraries to prepare for ESM before native ESM support.
Once a lib reached ESM and most of its consumers migrated, it may decide to do a breaking change and drop the compat API if maintaining compat it is no longer worth the cost (depends on the lib...).
The library may be initially authored using ESM and transpiled to CJS. In this case the step 3 corresponds to stopping the transpilation and directly publishing the ESM version.
mjs
+js
{default: Api}
Promise<Api>
Api
Api
Promise<Api>
Api
Api
Api
Promise<{default: Promise<Api>}>
{default: Promise<Api>}
default export
The lib replaces its CJS
module.exports
byexport default
in ESM.This pattern enables the migration of lib only if its consumers already use ESM.
This pattern relies on Node's ESM facade generation for CJS when importing them from an ESM consumer.
Examples:
CJS lib:
Equivalent ESM lib:
Example agnostic ESM consumer (=does not know the module type used by
lib
):Since
exports
is an alias formodule.exports
in CJS, the following are also equivalent:Agnostic ESM consumer:
The default export pattern allows library authors to have a common subset between their CJS and ESM implementation. Once they moved to ESM, they can do a minor update to extend their API using named exports. This may be useful to provide a more convenient access to the properties of the
default
export. The previous example can be extended as:ESM consumer
As mentioned at the beginning, this pattern allows libraries to migrate without breaking its consumers ONLY IF ITS CONSUMERS ALREADY USE ESM. This is the primary pattern available today with
--experimental-modules
.This means that the ecosystem migration using this pattern would have to start at the consumers and move up the dependency tree. (If you want to avoid breaking changes). It's good to have this option but it is not enough for the ecosystem to move quickly: it requires the lib to control its consumers.
This case is still relevant. Internal projects can use this to update: migrate the consumer, update the lib API, migrate the lib, repeat.
After thinking more about it, this pattern is also relevant in situation where you can rely on transpilation at the lib and consumer side. I mostly see it for front-end related frameworks, for example for Angular strongly encouraging Typescript. The lib can be authored using this pattern and transpiled to CJS, a consumer can then configure its build tool to import this lib and automatically get the default export (in Typescript, use
esModuleInterop
withallowSyntheticDefaultImports
, I think that Babel has something similar). This is not a true ESM migration because it still uses CJS under the hood, but the source code should be compatible.Promise-wrapped plain object
module.exports
is a Promise for a plain object equivalent to an ESM namespace. I'll abbreviate it as PWPO.I mention this pattern here because it was discussed, but I currently consider it as an anti-pattern. Using it enables a CJS-safe migration but if the consumer uses ESM, it breaks in suprising ways.
This ultimately creates a "race-condition" between the lib and consumer when they both try to move to ESM. To move transparently to ESM, the lib must assume that all of its consumer use CJS. If a consumer migrates before the lib, there will be breakage.
This pattern is useful if it already applies to your CJS lib. Do not use it as an intermediate state because it will require you to go through 2 breaking changes (initial to PWPO, then PWPO to something esm-safe).
This pattern relies on the fact that
require("esm")
returns a Promise for the ESM namespace. By settingmodule.exports
to a promise for a namespace-like object you can return the same value forrequire("./lib")
regardless of the module-type of lib.Given the assumptions above,
require("esm")
returns a Promise so it forces us to use promises in the compat API. ESM exposes a namespace object, you cannot export a function directly.It means that your compat API for CJS consumers is a Promise-wrapped plain object.
CJS lib:
Is equivalent to the ESM lib:
Example agnostic CJS consumer:
Here is another example exporting a single function, it uses an IIAFE for the promise:
CJS lib:
ESM lib:
Agnostic CJS consumer:
This pattern is more complex but allows you to migrate your lib to ESM, if your consumer use CJS.
As discussed at the beginning, this pattern causes unexpected breaking changes if the consumer moves to ESM.
Here is what would happen if the consumer from the last example moves to ESM:
If the consumer moves to ESM before the lib, two bad things happen:
default
property on the result changes fromFunction
toPromise<{default: Function}>
. This is highly unexpected and very confusing.The consumer would need to be very defensive when importing a lib using this pattern, this defeats the goal of allowing a simple migration: the consumer needs to intimately know the lib.
See "Promise-wrapped + facade" for an extension solving ESM-safety.
ESM facade
Use two files for the lib module:
.js
for the implementation and CJS API,.mjs
for the ESM API.This relies on the resolution algorithm to pick the right file depending on the module-type of the caller. This means that it relies on
.mjs
or any other mechanism that lets you have two versions for the same module. You can start using this pattern without native ESM support (the.mjs
will be ignored) but if you decide to add it afterwards, it may be a breaking change ifdefault
in.mjs
does not have the same value asmodule.exports
in.js
.You need both files in the lib:
CJS consumer:
The consumer can move to ESM and get expected results:
Your
.mjs
facade file can also expose named exports. I recommend to keep thedefault
export set to the default value of the lib.Lib:
CJS consumer:
The consumer can move to ESM and get expected results:
This relies on the fact the ESM lib module is picked first if the consumer uses ESM.
lib.mjs
acts as a static facade defining the named exports of the lib and allowing it participate in the ESM resolution.I use this kind of pattern to also expose named exports in my own projects. It probably deserves more documentation (easier to author/consume than the promise-wrapped plain object).
This scenario is an important reason for both
.js
and.mjs
.This pattern is nice, but unless you use the
.mjs
file for named exports, it boils down to manually doing what Node is doing automatically with--experimental-modules
. You still need to write your implementation in CJS and cannot migrate. The goal of this pattern is actually to update your API to expose named exports so your consumers can migrate to use named exports. To get rid of CJS, you'll need to go a step further and use the dual build pattern or promise-wrapped+facade.Promise wrapped + facade, by @bmeck
This pattern is a combination of promise-wrapped and ESM facade. It fixes the ESM-safety issue of PWPO by handling the ESM imports in a facade.
Your lib needs an implementation file and two entrypoints (one for CJS and one for ESM):
Example CJS consumer:
Example ESM consumer
This pattern forces your CJS consumers to use an async API, this is less convenient.
The upside is that it allows you to do a safe migration of your impl file from CJS to ESM (you are not stuck with a CJS impl as with a simple ESM facade pattern).
Here is how to update your lib once you moved the impl to ESM (you can merge impl with lib.mjs):
Dual build
This pattern is an extension of the ESM facade pattern. Instead of re-exporting the values defined in CJS, define your values both in CJS and ESM so both files are independent.
Example lib:
The consumers are the same as in the ESM facade:
Given the current constraints, I feel that this is the pattern providing the best consumer experience: the lib can adopt ESM and drop CJS without breaking the consumers (assuming it leaves time for the consumers to move 😛 ) and does not depend on the module type of the consumer. The consumer gets expected results when migrating.
The obvious drawback is that both files MUST provide the same values to actually ensure the consumer can migrate without surprises. It means that the lib needs to use tooling for this use case. This should be easy to achieve if the source-code is transpiled using Typescript or Babel. If you are writing the files manually, it's best to avoid. This also means that this pattern requires you to keep using tooling for the duration of the transition, even if one of the goals of ESM was to allow more use-cases without tools. Native support by ESM will not affect the benefits of using Typescript but some teams may consider removing the Babel overhead.
This is the pattern I settled on for my personal projects, but I spent a lot of time tinkering with my build tools.
Default Promise
This pattern gives you full compat without relying on "js + mjs", at the cost of having a user-hostile API.
CJS lib
ESM lib
CJS consumer:
ESM consumer
I found it by combining the constraints of both "default export" and PWPO.
This pattern allows you to support any combination of lib and consumer module type, using a single file.
It's good to know that this exists, but the API is so bad (you need to await a promise twice) that I hope that nobody will have to use this. Still, it offers an escape hatch if resolution based on the consumer (mjs+js) is not available.
Transparent interop
Hehe, you'd like it. Unfortunately I don't know how to achieve it today, but at least I can give you a definition of a transparent interop pattern.
Transparent interop would be a library pattern (meaning the libraries may need to change their code) such that:
"Importing
default
is the same as the CJSmodule.exports
" and/or "Named imports are the same as the CJS properties ofmodule.exports
"If any of those is not achieved then we can't call it transparent interop and the migration will be measured in decades.
Ideally it would be simpler to maintain than "Dual Build" and less user-hostile than "Default Promise".
Forewords
Ecosystem migration bias
The current path to migrate the ecosystem from CJS to ESM has a dependency between the lib and consumer. The migration is biased in favor of the consumer: he can migrate more easily than its libraries.
Sync
require("esm")
If we had sync
require("esm")
, the situation would be:A migration-safe solution exists using something like:
CJS lib:
And the equivalent ESM implementation
You can use it this way:
But sync
require("esm")
is impossible due to timing issues.Again, I'm hoping that someone can find a pattern for "transparent interop" without sync require or a way around the timing issues.
require("esm")
Edit: I wrote this before knowing about "promise-wrapper and facade", I am less worried about the use cases now.
I am not sure about the use case for an async
require("esm")
.It enables the Promise-Wrapped Plain Object pattern for CJS-safe lib migration, but using it this way is a footgun because of the surprising behaviors and breaking changes if the consumer uses ESM.
If you remove the "transparent interop" use case, I see:
require("esm")
instead ofimport
here? You already know specifically that the lib uses ESM, and if your runtime supports ESM then it also supportsimport(...)
.import(...)
reliably so you can backport it usingPromise.resolve(require("esm"))
. But then you need to deal with a whole can of worms anyway to actually understand what's going on in the module. My example for this use case is a lib likemocha
: it wants to import test specs that may be written in CJS or ESM, and has to work on versions where usingimport(...)
throws an error. Actually,mocha
had a PR to handle ESM: it simply usedeval(import(specifier))
. Still, we are talking about very specific use cases where I expect implementors to be familiar with Node's module system and already have to deal with edge-cases.require("esm")
may be nice for them, but there are already workarounds.I am not convinced that
require("esm")
has use cases that aren't better served byimport(...)
, even considering interop. I'd be happy to hear more about it.@jdalton's
esm
package and other tools@jdalton did some great work with his
esm
package. I deliberately chose to not talk about loaders or other advanced features here, but until we get true native ESM everywhere using tools like this will definitely help. The situation is not that bad. It may require a bit more work by the consumer but at some point it's unavoidable.The text was updated successfully, but these errors were encountered: