Skip to content
This repository has been archived by the owner. It is now read-only.

Node interopability with default exports #85

Closed
joliss opened this issue Dec 7, 2013 · 72 comments
Closed

Node interopability with default exports #85

joliss opened this issue Dec 7, 2013 · 72 comments
Labels

Comments

@joliss
Copy link

@joliss joliss commented Dec 7, 2013

This is not a bug in the transpiler, but I thought this repo might be a good place to have this discussion:

I want ES6 modules to succeed, because it offers some clear technical advantages. But if we write our modules in ES6, we'll generally want to also transpile them to Node's CommonJS to publish them on npm, so good CJS interopability will be important for ES6 adoption.

But there's an interop problem: Say you have module foo, and it has a single export (export default Foo, to be used like import Foo from 'foo'). Right now, this transpiles to exports["default"] = Foo, to be used like var Foo = require('foo').default. This extra .default is clearly suboptimal, in that it breaks the convention of the existing Node ecosystem. I worry that having .default will be unappealing and make our transpiled modules look like "second-class Node citizens".

"Oh," you say, "but we can simply wrap our transpiled modules in a bit of helper code to get rid of the .default." (See the handlebars wrapper for Node for an example.) Sadly, I believe this actually makes things worse: Say another package "bar", written in ES6 as well, has import Foo from 'foo'. When bar is transpiled to CJS, it will say (approximately) var Foo = require("foo").default. The transpiler cannot know that "foo" is specially wrapped on Node and doesn't need .default, so now we need need to manually remove the .default in bar's CJS output. (Am I missing something here?)

I've also heard people suggest that Node could simply adopt ES6, so that these troubles would be irrelevant. But switching Node to ES6 is probably not a matter of just enabling the syntax in V8. Rather, the big hurdle is interopability with the existing ecosystem. (Also keep in mind that Node doesn't have a pressing need to switch to ES6.) So if Node is to adopt ES6 modules at all, figuring out a good interop story is probably a prerequisite.

So here are some possible solutions, as I see them:

  1. Accept that .default will be all over the place on Node.
  2. Or, try to wrap modules manually to remove the .default on CJS. As I point out above, this might get troublesome once we have an ecosystem of packages written in ES6 that need to also play together on Node.
  3. Or, a daring suggestion: Change the CJS transpilation output to omit .default. (Or add a "Node" mode in addition to CJS that omits .default.) So export default Foo would transpile to module.exports = Foo, and import Foo from 'foo' and module Foo from 'foo' would both transpile to var Foo = require('foo'). (If, transpiling to CJS, a module has both default and named exports, we might throw an error, or require some kind of directive for the transpiler, [update:] or tack the named exports onto the default object, see @caridy's comment below.) This change would acknowledge that .default is really something you never want on Node. It falls short when modules have default and named exports. (Does this happen much at all?) I believe it also makes circular default imports impossible to support. This is fairly easy to work around though - intra-package cycles can use named imports, and inter-package cycles are very rare.
  4. [Update:] Or make Node's module loading ES6 aware somehow. See @domenic's comment below.
  5. [Update:] Export default as the root-level object, and tack a property like ._es6_module_exports onto it for named exports. See @stefanpenner's comment below.
  6. [Update:] Export default as the root-level object only if there are no named exports. Use an ._es6Module property to preserve ES6 semantics. See my comment way below.

What do you think about those? Any other ideas?

@substack

This comment has been minimized.

Copy link

@substack substack commented Dec 7, 2013

I would have thought this module would already do the module.exports = Foo thing when it encounters an export default Foo in a package because export default was included because node and other package ecosystems proved the value of single-exports and without it interop would have been really painful. I'm very +1 on converting default exports to module.exports.

@domenic

This comment has been minimized.

Copy link

@domenic domenic commented Dec 7, 2013

The problem is that the ES6 semantic module and the Node/AMD semantic model are fundamentally incompatible, by design (X_x). Trying to reconcile them, e.g. by making a default export and the module instance object into the same thing like Node does, will lead to numerous problems of a much worse and more subtle sort than the existing ones.

The existing solution has the virtue that transpiled code will work the same as non-transpiled code. On the contrary, the proposed "daring suggestion" will break that property. You will build up a large library of code meant to work with the transpiler, and then try to use native ES6 support, and everything will break, because you used the transpiler's daring conflating-semantics instead of ES6's separation-semantics. This hazard seems, to me, unacceptable.

The best solution, I believe, is to work on modifications to Node's module loading mechanism to be ES6 aware. This could be done in user-space with require.extensions[".js"] hacks. This is the only forward-compatible path I see that does not blow up when actual ES6 support lands in V8.

@stefanpenner

This comment has been minimized.

Copy link

@stefanpenner stefanpenner commented Dec 7, 2013

I believe this is a solved problem. @wycats/@thomasboyt should likely confirm

export $, { ajax } from 'jQuery';

// tag the root exported object with the es6 additions
jQuery._es6_module_something = {
  ajax: jQuery.ajax
};

module.exports = jQuery;

imports should be mostly compatible

@joliss

This comment has been minimized.

Copy link
Author

@joliss joliss commented Dec 7, 2013

So (Stef please correct me if I misunderstood) the idea is that the transpiler would tack ._es6_module_exports onto the default export. This works because the default export must always be an object or a function, never a primitive type. Node supports defineProperty, so we can use that to make _es6_module_exports non-enumerable as well, to minimize the chances of the property getting in the way.

This seems reasonable to me.

@caridy

This comment has been minimized.

Copy link
Contributor

@caridy caridy commented Dec 8, 2013

This problem goes beyond the server use-case IFAIK, for example: when trying to do interoperation between an AMD module and transpiled to AMD module, which is a common use-case if you have a large scale project with a lot of legacy pieces where new pieces are going to be written in ES6. The AMD module will have to know the nature of the transpiler when importing stuff (e.g.: know that mod['default'] is the actual default export).

we have been thinking about this issue for a while, and the only solution that I can think of is the inverse of what @stefanpenner is proposition, here is the pseudo code for a module exporting a default function:

// es6
function Foo() {
     // default function to export
}
export default Foo;
// transpiled to CJS
function Foo() {
     // default function to export
}
module.exports = function () {
     return module.exports['default'].apply(this, arguments);
};
Object.defineProperty(module.exports, 'default', { value: Foo });

this approach will work just fine in CJS, AMD and YUI, we can define more properties on module.exports, in case we need more regular exports, and we can even freeze them to be more align with ES6. Of course, for older browsers we can use regular properties.

As for exporting a default object instead of a function, we can do the same by using the function as a shim for the actual object:

// es6
function SomethingElse() {
     // function to export
}
var foo = {
     bar: function () {},
     baz: 2
};
export default foo;
export SomethingElse;
// transpiled to AMD
function SomethingElse() {
     // function to export
}
var foo = {
     bar: function () {},
     baz: 2
};
module.exports = function () {
     // we could throw in here...
};
Object.defineProperty(module.exports, 'bar', { value: foo.bar });
Object.defineProperty(module.exports, 'baz', { value: foo.baz });
Object.defineProperty(module.exports, 'SomethingElse', { value: SomethingElse });

Does this makes any sense? /cc @ericf

@domenic

This comment has been minimized.

Copy link

@domenic domenic commented Dec 8, 2013

I would encourage everyone to re-read #66 and #69, where this all has been discussed before. In particular, they give concrete examples of the hazard I spoke of above, where transpilation strategies like this would allow you to write code that is not ES6-semantics compatible, even though it uses ES6 syntax.

@stefanpenner

This comment has been minimized.

Copy link

@stefanpenner stefanpenner commented Dec 8, 2013

@caridy not saying anything negative about your suggestion, but my example was not a proposition, rather I believe it is the solution @wycats / @dherman / @thomasboyt and friends came up with.

Although the transpiler does not yet implement it yet, the plan is for it too.

If you have specific concerns, it is likely important to bring them up.

@joliss

This comment has been minimized.

Copy link
Author

@joliss joliss commented Dec 8, 2013

@domenic wrote:

The best solution, I believe, is to work on modifications to Node's module loading mechanism to be ES6 aware. This could be done in user-space with require.extensions[".js"] hacks. This is the only forward-compatible path I see that does not blow up when actual ES6 support lands in V8.

It sounds like these hacks would have to be activated before require'ing the transpiled module in question. As a library author, if I'm putting an ES6-to-Node-transpiled module on GitHub (say like rsvp), I probably wouldn't want to ask users to activate hacks before require'ing my module, would I?

Also, would we be able to isolate this to ES6 modules? I'm also worried that this might make the module loader infrastructure brittle in subtle ways.

Can you elaborate a bit more on what you're envisaging?

@joliss

This comment has been minimized.

Copy link
Author

@joliss joliss commented Dec 8, 2013

Re @caridy's suggestion: @caridy, I'm not sure I understand your code, especially why the default export is turning into a function. Ping me on IRC maybe? I also believe @domenic's worry of creating a hazard when we move to untranspiled ES6 is quite justified: We wouldn't want import defaultExport from "foo"; console.log(defaultExport.namedExport) to work in transpiled code, because it doesn't work in ES6.

@joliss

This comment has been minimized.

Copy link
Author

@joliss joliss commented Dec 8, 2013

Re @stefanpenner's solution:

Stef, I wonder about the problem that @caridy alluded to: In untranspiled code, how are we planning to consume named ES6 exports? It seems to me that you'd end up with

define(["jQuery"], function(jQuery) {
  // This is not transpiled, but actual code that library users would have to write:
  var ajax = jQuery._es6_module_exports.ajax;
});

or

var ajax = require('jquery')._es6_module_exports.ajax;

This syntax is so awkward that, as a library author, you basically can't use the named exports feature for your external API. (Assuming you care about Node or AMD, which most of the time you do.) You could of course copy your library's named exports onto the default export object, but that seems cumbersome, at least for new modules.

Is this limitation acceptable? (Or am I missing some other solution?)

@domenic, am I correct that the forward-compatibility hazard you're talking about doesn't apply to Stef's example? I believe module foo from "foo" would transpile to var foo = require('foo')._es6_module_exports. So as long as code doesn't rely on having the clearly-transpiler-internal _es6_module_exports property on the default export, would this allow anything in ES5 that would be impossible in real ES6?

@caridy

This comment has been minimized.

Copy link
Contributor

@caridy caridy commented Dec 9, 2013

About the "In untranspiled code, how are we planning to consume transpiled modules?" scenario, what we do today in YUI is to have a feature flag (very similar to _es6_module_exports), plus a small modification on the YUI loader to be able to load those modules transpiled from ES6, and treat them differently. To be precise, in you look into the YUI transpiler, we add "es": true token into the transpiled module meta to let the YUI Loader to treat them differently in terms of default exports vs regular exports. We could perfectly use the _es6_module_exports flag instead. So, it seems that the problem that we are discussing here is a multi-dimensional problem that involves the transpiler and the corresponding loader used to load those transpiled modules.

My main concern though, is the future, because today we have a two way street with one way being block by construction :), and that's why we are looking for solutions to write ES modules and be able to use them in some of the existing loader implementations, but the problem of tomorrow (when the blocked lane gets ready) is going to be how to load legacy modules (AMD, CJS, YUI, etc) thru an ES Loader instance without changing them (or transpiling them), otherwise we will end up transpiling them into ES, lol.

That's why var ajax = require('jquery')._es6_module_exports.ajax; is just unacceptable today, because it is going to be a pain in the future when ES jquery module gets loaded by an ES Loader, and gets used by a legacy CJS module that is trying to access the ._es_module_exports member, and there is little that the loader can do to facilitate that because it will not know how the legacy module is going to use the imported ES module.

@caridy

This comment has been minimized.

Copy link
Contributor

@caridy caridy commented Dec 9, 2013

btw, if we end up choosing _es6_module_exports, lets make sure we use _es_module_exports instead, adding the version of the spec is an anti-pattern.

@wycats

This comment has been minimized.

Copy link
Contributor

@wycats wycats commented Dec 9, 2013

es_module_spec is a bit silly because technically node and AMD are all ES.

Because of 1JS, ES7 modules will just be a superset of ES6 modules. What about _syntactic_modules_ or _built_in_modules_.

Another option would be for ES friendly loaders to produce a generated symbol (a la jQuery) at System.brand, and loaders use that one if they find on boot, or generate a new one if not.

Then all generated modules can do exports[System.brand] = ...

@thomasboyt

This comment has been minimized.

Copy link
Contributor

@thomasboyt thomasboyt commented Dec 9, 2013

the package is called called es6-module-transpiler, let's just call them __es6_transpiled_module__s

@caridy

This comment has been minimized.

Copy link
Contributor

@caridy caridy commented Dec 10, 2013

@wycats: I think the generated symbol thru System.brand is flexible enough.

But we have been trying out few ideas with the loader, and there is one thing that is is getting messy when using System.import() to import various modules. For what I can tell, System.import() will provide access to an object with all named exports, and it is the responsibility of the user to access the default export manually, which is different from all other loaders out there (cjs, yui and amd), which means we will end up doing something like:

System.import(['foo'], function (foo) {
     // how to access the default export in here? maybe `foo[System.brand]`?
     // and how to access a named export in here? maybe `foo.something`?
});

The same applies when loading a transpiled module into amd, cjs and yui. It seems that no matter what we do at the loader level, System.import's callback is expecting a very simple api where default is, in fact, a member that you access intentionally in the callback.

The question is: does this means that default exports are mostly for module-to-module interoperability while named exports are more suitable for app level code?, it seems to me that that is the case, which I'm totally fine with it. Which means you will do something like this:

System.import(['jQuery'], function ($) {
     $.ajax(); // which is nice and clean, but what about default exports?
});

which is going to be equivalent to use the import syntax for a named export at a module level:

import {ajax} from 'jQuery';

So far so good, it looks nice, clean, but that's only if you DO NOT use the default export for jQuery module.

This is why we were playing around with the shim mechanism described in #85 (comment), to try to get to a middle ground where:

System.import(['foo'], function (foo) {
     // `foo()`to execute the default export
     // and `foo.something` references to a regular named export
});

Is this ideal? is this doable? I don't know :).

@domenic

This comment has been minimized.

Copy link

@domenic domenic commented Dec 10, 2013

System.import(['foo'], function (foo) {
    // `foo()`to execute the default export
    // and `foo.something` references to a regular named export
});

This is exactly the kind of code I am worried about. ES6 does not work this way. You need to do foo.default() in ES6 to access the default export. foo is a module instance object, always: i.e. a null-prototype, frozen object with a bunch of getters which live-get the binding value. It is not the default export, and is nothing like the AMD/Node semantics you (and everybody else) are wishing for.

@caridy

This comment has been minimized.

Copy link
Contributor

@caridy caridy commented Dec 10, 2013

@domenic alright, fair enough. I'm sold with solution 1 from @joliss then, If .default() is here to stay, I'm good with that. I guess the best way to educate people on this is to avoid the sugar for default when talking about ES6 modules. E.g:

import { default as something } from 'foo';

instead of

import something from 'foo';

because it is easier to translate that (visually) to others systems:

var something = require('foo')['default'];

or

define(['foo'], function (foo) {
    var something = foo['default'];
})

and even when it comes to load it at the app code level:

System.import(['foo'], function (foo) {
    var something = foo['default'];
});
@joliss

This comment has been minimized.

Copy link
Author

@joliss joliss commented Dec 15, 2013

I can't figure out how the __es6ModuleExports solution would support cycles for named imports.

Say you have module a.js reading

import { bar } from 'b'
export default function() {} console.log('In a. bar = ' + bar)
export let foo = 'foo'

and package b.js reading

import { foo } from 'a'
export default function() {} console.log('In b. foo = ' + foo)
export let bar = 'bar'

My understanding of the proposed solution is that the transpiled a.js would look something like:

var _b_module = require('b')
module.exports = function() { console.log('In a. bar = ' + _b_module.__es6ModuleExports.bar) }
module.exports.__es6ModuleExports = { 'default': module.exports } // perhaps
module.exports.__es6ModuleExports.foo = 'foo'

(b.js analogously.)

Because the module.exports object needs to be replaced with a new function object in line 2, the circular require won't work.

@caridy

This comment has been minimized.

Copy link
Contributor

@caridy caridy commented Dec 15, 2013

I have the same question @joliss, if we have to deal with default anyways, how will __es6ModuleExports help aside from the mere fact that it flags the module as ES module so loader can behave slightly different.

@wycats

This comment has been minimized.

Copy link
Contributor

@wycats wycats commented Dec 16, 2013

@joliss in your final example, can you show me how you would expect that to work with existing CommonJS modules?

@wycats

This comment has been minimized.

Copy link
Contributor

@wycats wycats commented Dec 16, 2013

@joliss or is your question about the fact that the claim is that we could make the full set of circularity features work when compiling from ES6 modules, while maintaining compatibility with existing node practice?

@wycats

This comment has been minimized.

Copy link
Contributor

@wycats wycats commented Dec 16, 2013

@joliss here is a gist of how I imagine it would work: https://gist.github.com/wycats/7983305

@joliss

This comment has been minimized.

Copy link
Author

@joliss joliss commented Dec 17, 2013

@wycats, I think your gist to make cycles work in a.js and b.js looks good. (Yes, I only care about circularity within transpiled ES6.)

@joliss

This comment has been minimized.

Copy link
Author

@joliss joliss commented Dec 17, 2013

So to summarize for everyone, the idea would be: When ES6 modules are transpiled to CommonJS (and presumably AMD), their module object is the default export:

// Get ES6 `default` export from transpiled 'metamorph' module
var Metamorph = require('metamorph')

ES6 named exports are accessible from other transpiled ES6 modules (implemented via a hidden __es6_modules__ property tacked onto the default export object) but are inaccessible from untranspiled Node and AMD code.

This would mean that ES6 modules that need to work on Node will have to generally expose their named exports on the default object:

export { foo, bar }
// foo and bar are part of this package's external API, so we add an
// artificial default export to make them accessible from Node:
export default { foo: foo, bar: bar }

Note: Therefore in practice, any code that needs to work on Node cannot have named exports and a separate default export. That's OK, because Node simply doesn't have the necessary module semantics.


Let's go the other way and ES6ify an existing Node module. Say you have a module with this interface:

exports.foo = 'foo'
exports.bar = 'bar'

If you wanted to migrate the source to ES6, but continue to provide the transpiled output to Node, then this is equivalent ES6 code:

export default { foo: 'foo', bar: 'bar' }

Presumably, you would also add named exports as a new API into your module:

export default { foo: 'foo', bar: 'bar' }

// As a courtesy to our fellow ES6 users, we add two named exports to our API:
export var foo = 'foo'
export var bar = 'bar'
@joliss

This comment has been minimized.

Copy link
Author

@joliss joliss commented Dec 17, 2013

I want to argue one more thing: To ES6 code, non-ES6 Node modules should look like they have one default export and no named exports.

First, observe that clearly this should work

import mkdirp from 'mkdirp' // mkdirp is a regular Node module

as it's semantically correct. (I have seen people suggest module mkdirp from 'mkdirp'; mkdir(...) // yuck, but as @domenic has pointed out this breaks if mkdirp ever becomes an ES6 module, because there is no way to expose this kind of interface from ES6.)

Now it's tempting to try and make this work:

module fs from 'fs'
fs.stat(...)

But I want to argue that the 'fs' module should only expose its exports on the default object, so you should write this instead:

import fs from 'fs' // yes, really
fs.stat(...)

Here's why:

First, to make module fs from 'fs' work, we'd have to guard against fs being a function, so we'd need to copy stuff around. I think we should avoid this. That's because fs is a foreign object not under the transpiler's control, so I'd anticipate there will be weird edge cases where the behavior is slightly wrong - mucking with foreign objects is fundamentally asking for trouble. (This is unlike tacking on the .__es6_module__ property, which is acceptable mucking because it only gets tacked onto objects under the transpiler's control, not foreign objects. So if I use the transpiler, I know to expect this, and I can accommodate it in my own source.)

Second, if module fs from 'fs'; fs.stat(...) worked, it would mean we are now promising both of these to work in ES6 land:

import fs from 'fs' // works because 'fs' has a default export
fs.stat(...)

// *and*

import { stat } from 'fs'
stat(...)

So if you ever were to turn fs into an ES6 module and supply transpiled source, you'd have to have a default export with a stat property, and a named stat export.

That might be an acceptable promise for stat, but we'd be making this promise even for properties that happen to exist on the default export but don't necessarily make sense as named exports:

var someObject = new Foo
someObject.someProperty = 'I am internal to the Foo instance'
module.exports = someObject

If I ES6ify this, I technically have to export var someProperty = someObject.someProperty as a named export, because some fool might be doing import { someProperty } from 'module' in their library, and I don't want to break people's code. That's not good.

So in summary, for a regular non-ES6 Node module 'fs', module fs from 'fs' should simply amount to var fs = { 'default': require('fs') }, and import { stat } from 'fs' should amount to var stat = undefined. Given the tradeoffs, I think this is an acceptable limitation.

@joliss

This comment has been minimized.

Copy link
Author

@joliss joliss commented Dec 17, 2013

Finally, I believe all of the above should work the same way in AMD land.

@guybedford

This comment has been minimized.

Copy link
Contributor

@guybedford guybedford commented Dec 18, 2013

@joliss thanks for starting a discussion around this - it is important stuff to work out.

I like the ideas, I'm just trying to work this out. Apologies in advance if I'm missing something, but let me know if this sounds about right with what you are suggesting:

ES6 Module:

  import { q } from './some-dep';
  export var p = 'hello';
  export default 'test';

CommonJS Transpiled Version

  var q = require('./some-dep').__es6_module.q;
  module.exports = 'test';
  exports.__es6_module = {
    p: 'hello',
    default: module.exports
  }

By adding this es6_module metadata, we've now changed the underlying default export surely?

Or does the require() statement resolving to the default import only happen in NodeJS?

@joliss

This comment has been minimized.

Copy link
Author

@joliss joliss commented Dec 19, 2013

@guybedford Yes, that's the plan; we'd be using defineProperty to make the __es6_module property "invisible" to enumeration, so that it doesn't interfere. Note that this doesn't work on strings (export default 'test' in your example) -- I'll leave a separate comment about that -- but it works if your default export is a function or object.

Here is an example illustrating how defineProperty works:

var defaultExport = function () {}
defaultExport.regularProperty = 'foo'
// Like `defaultExport._es6Module = { namedExport: 'test' }`, but not enumerable:
Object.defineProperty(defaultExport, '_es6Module', {
  enumerable: false,
  value: { namedExport: 'test' }
})

// _es6Module can be read like a regular property ...
console.log(defaultExport.regularProperty) // => foo
console.log(defaultExport._es6Module) // => { namedExport: 'test' }

// ... but is invisible to enumeration
console.log(Object.keys(defaultExport).indexOf('regularProperty')) // => 0
console.log(Object.keys(defaultExport).indexOf('_es6Module')) // => -1
for (key in defaultExport) if (key === '_es6Module') throw 'never happens'

// As an aside, note that hasOwnProperty is still true
console.error(defaultExport.hasOwnProperty('_es6Module')) // => true
@joliss

This comment has been minimized.

Copy link
Author

@joliss joliss commented Dec 19, 2013

When the default export has a primitive type (string, number, boolean, null, undefined), the _es6Module trick does not work, because someString._es6Module = {} silently fails. This becomes a problem when we want to have both a primitive default export and named exports. I see several solutions for this edge case:

  1. Silently drop any named exports. This is functionally undesirable, but it can be implemented without extra code in the transpiled output, by simply letting the JS VM silently drop the _es6Module property.[1]
  2. Check the typeof the default export at run time. If it is primitive, and there are named exports, throw an error. This is similar to (1) in that primitive-default-plus-named-exports would in effect be unsupported by the transpiler, but it fails noisily instead of silently. It will require a bunch of extra code to do this.
  3. Check the typeof the default export at run time. If it is primitive, and there are named exports, make the default export invisible to untranspiled modules. In such cases the module would only export { _es6Module: { 'default': 'some primitive', namedExport: ... } }. It seems to me that this is the most desirable behavior. It preserves functionality in ES6 land. You'll only have to know to avoid this when you are designing an interface to be consumed by non-ES6 code (e.g. the public interface of a Node module). But named ES6 exports are rare in public interfaces anyway. The disadvantage of this solution is that it adds complexity. I worry that there will be hidden problem that are only found very late, because this code path is used so rarely.

I'm uncertain. I was going to suggest (3), but this scenario is so edge-casey that perhaps we can go with (1) for now, and document it as a to-do.

[1] Technical side-note: The named exports would be briefly visible to cyclic imports, and then disappear once the primitive default exported is exported.

This was referenced Mar 4, 2014
@juandopazo

This comment has been minimized.

Copy link

@juandopazo juandopazo commented May 7, 2014

I'd like to reopen this conversation with a 👍 for Guy's point about the __transpiledModule flag. Given that CJS or AMD modules should only be interpreted as modules with a default export by ES6 systems because of their dynamic nature, we can assume that whatever was imported by the transpiled-to system is not an ES6 module, unless it was marked by the transpiler as one.

@eventualbuddha

This comment has been minimized.

Copy link
Contributor

@eventualbuddha eventualbuddha commented May 15, 2014

I'm attempting to solve some of the outstanding issues in this project and this has struck me as one of the biggest. I'm going to brain-dump here in the hopes that it'll help me grok the situation.

node.js interop

The question is: How do I expose the ES6 interface to ES6 modules and the CommonJS interface to CommonJS modules? Let's assume we have this code with two separate npm packages:

// exporter.js
export default { a: 1 };
export var b = 2;

// importer.js
import value from 'exporter';
import { b } from 'exporter';
console.log(value.a);
console.log(b);

Proposal: __esModule

My understanding is that this object would be attached to the default export, if any, of a module when targeting CommonJS. This means that:

  • modules with export default EXPR cannot fully participate in cycles
  • EXPR must be extensible
  • EXPR cannot have a property named __esModule
  • CommonJS consumers (e.g. require('exporter')) see the default export, as you'd probably expect

This would restrict us to a subset of valid ES6 module behavior that should theoretically allow the code to run unmodified in a real ES6 module environment.

Our sample code would become something like this:

// exporter.js
module.exports = { a: 1 };
var b = 2;
var $__0 = Object.seal({
  default: module.exports,
  get b() { return b; }
});
module.exports.__esModule = $__0;
if (module.exports.__esModule !== $__0) {
  throw new Error('default exports targeting CommonJS must be extensible objects');
}

// importer.js
var $__0 = require('exporter');
$__0 = $__0 && $__0.__esModule || $__0;
console.log($__0.default.a);
console.log($__0.b);

Proposal: __transpiledModule

This is a boolean flag attached as another named export. This means that:

  • cycles should work for any modules configured correctly, even with default exports
  • in export default EXPR, EXPR can resolve to any value, even non-extensible values
  • modules cannot have an export named __transpiledModule
  • CommonJS consumers would need to access the default export by accessing the default property, which may not be expected
// exporter.js
var b = 2;
Object.seal(Object.defineProperties(exports, {
  default: { value: { a: 1 } },
  b: { get: function() { return b; } },
  __transpiledModule: { value: true }
}));

// importer.js
var $__0 = require('exporter');
if (!$__0 || !$__0.__transpiledModule) { $__0 = { default: $__0 }; }
console.log($__0.default.a);
console.log($__0.b);

Thoughts

These two proposals are essentially inverses of each other, as far as I can tell. In one, we favor treating the result of require() as a module that may have a default property. In the other, we favor treating the result of require() as the default export. The main distinction is that one preserves node semantics around default exports, the other preserves ES6 cycle behavior.

So which do we prefer? Or is there some other solution that fixes both these issues? Or perhaps I've misrepresented one or both of these?

@caridy

This comment has been minimized.

Copy link
Contributor

@caridy caridy commented May 15, 2014

Yours is a very tricky question :), if we are transpiling from ES6, it is imperative to maintain the semantics and the ES behavior, otherwise the following statement becomes true: "not all ES6 modules can be transpiled to CJS", and that will be a shame. Can we survive that statement? we certainly can, we have been doing modules without cycle behavior for a long time.

On the other side of the coin, your target module system (CJS) is what matters, it is where you will be using those ES6 modules, thereby, focusing on the semantic around default exports is probably the best we can do, even if that means sacrificing the cyclical references from ES6.

I know, I know, I didn't choose one, but I guess I'm shooting for a the future here (one no so far away future, hopefully), where we will be using System.import() in nodejs, and require() will become a legacy syntax that this loader might wrap around to load legacy cjs modules instead of the other way around.

@ericf

This comment has been minimized.

Copy link
Contributor

@ericf ericf commented May 17, 2014

@eventualbuddha @caridy both of you have summed up where we are on this issue nicely, but they one additional perspective I'd like to add around context.

If I'm writing application code using ES6 syntax which I want to run in Node.js, and I don't intended to share this code (e.g., it's not a library, "private": false in the package.json); then I want to maintain the ES6 semantics. Since I'd be writing both the exporter and importer modules — both of which will also be transpiled — the __transpiledModule proposal above fits best.

If I'm writing a library which I intended to share with people which I want to run in Node.js, then I'd rather that library play nice within that ecosystem. In this case I can't (and shouldn't) assume the importer is written as an ES6 module that will be transpiled, so I'd prefer the __esModule proposal above because it fits with my consumer's expectations around the default export.

To me, it's this context which dictates which features get priority and which edge-cases/features I'm willing to give up on. This leads me to believing that we need both options. And we can provide guidance on which one you choose and when.

@guybedford

This comment has been minimized.

Copy link
Contributor

@guybedford guybedford commented May 18, 2014

I think there can be a middle ground between the two and that is basically with a forked compile process in the __transpiledModule compilation option (I am using the __esModule flag in the examples though, as I think we've converged on this flag).

This is the method I've been using within SystemJS, and it is working very well within the ES6 environment. So my perspective very much comes from there. Hopefully by explaining it clearly you can verify if it can work in the CommonJS scenarios. If not, I've at least given my opinion.

Yes I am repeating myself, so if you've heard this all before and I've missed the arguments please do say so.

When compiling I can indicate whether I want the default export to become the CommonJS module itself, or whether I want the exports to be added separately.

Consider:

  export default 'test';
  export __useDefault: true; // flag tells compiler that the module in CommonJS should be interpreted as the default export

-> compiles into

  module.exports = 'test';

We don't need to add any __esModule flag here, because we effectively no longer have a transpiled ES6 module, but rather we defined a CommonJS-style module but within ES6. We lose ES6 circular references, but can still keep CommonJS style circular references.

Without that same flag we compile exports separately. And add the __esModule flag.

So we allow the default handling to be a fork within the transpilation process. Note that this thinking is very much the inverse of the other method because my starting point is ES6.

In SystemJS this __useDefault flag is always necessary when loading CommonJS or AMD, because it's the only way to distinguish them from normal ES6 modules when loaded within other CommonJS or AMD modules.

@sheerun

This comment has been minimized.

Copy link

@sheerun sheerun commented Nov 21, 2014

Sorry, I'm not an expert in ES6 modules. Can someone explain me why do we need the default export in transcompiled output? It seems like implementation detail of ES6. Is it really necessary to export it?

I understand this decision has been made because "The default export is just another named export". But "imports" and "exports" are only ES6 concepts. There are no "imports" and "exports" in ES5. It means that trancompilers can do whatever they want with regard to "default" field, and they should do the best they can to support existing module systems. That's what transcompilers are for, right? Why not just export default field as root object, preserving semantics of CommonJS / AMD modules?

As far understand there are few cases:

  • My code is ES6, I run my code natively in ES6, I consume ES6 dependency.
    • No problem. The "default" is handled as part of ES6 module syntax.
  • My code is ES6, I want to consume the default of trancompiled ES6 dependency.
    • import foo from "foo" can just transcompile to var foo = require('foo')
  • My code is ES5, I want to consume the default of trancompiled ES6 dependency
    • var foo = require("foo")

Note you never want to require("foo").default to import root object, because it assumes ES6 module system semantics. In CJS require("foo").default is importing the "default" field from root object.

You can't clash on "default" label because it's reserved word in ES6.

The only thing you can clash on is some field of exported default, for example:

export default { bar: "bar" }
export bar() {}

ES6 native runtime has no problem with consuming it. The only non-clear thing is what should following do when consuming this module from ES5: var bar = require('foo').bar. I think avoiding such case by issuing a warning "this export is ambiguous for ES5" is better option than breaking CommonJS/AMD module system semantics. I do think asking developers for non-ambiguous exports is good tradeoff.

@trek trek mentioned this issue Nov 23, 2014
2 of 5 tasks complete
@guybedford

This comment has been minimized.

Copy link
Contributor

@guybedford guybedford commented Nov 24, 2014

@sheerun it is certainly possible to make an ad-hoc rule that when there is only one default export, the transpiler treats that as module.exports when converting to CommonJS. 6to5 has implemented this feature recently in babel/babel@e3b8fa9.

@sheerun

This comment has been minimized.

Copy link

@sheerun sheerun commented Nov 24, 2014

@guybedford Much better, but here is the deal:

This ad-hoc rule implies that using multiple exports when transcompiling to ES5 is not-so-good idea, because you suddenly can't consume default object from non-transcompiled ES5 without knowledge that you're consuming transcompiled output: var foo = require('foo').default.

Think about what is 6to5 only job: Transcompiling to ES5. Always. I have hard time comprehending why is there even such thing as CommonJSFormatter. Or maybe rather: Why is there a formatter that uses ES6 conventions for imports and exports in transcompiled output? :)

Anyway, it means using multiple exports in 6to5 is always not-so-good idea. Unless you make a rule that default export is, no matter what, exported as root object.

What I mean is: if you transcompile to ES5 + CommonJS, please transcompile to it like you mean it. Please don't make transcompiled output be like "ES5 + CommonJS + ES6 export conventions, make sure you use our way of importing this module".

As I said, I don't fully comprehend ES6 modules, and that's why I'm asking for explanation of default in transcompiled output. Is there even one case when it is / will be useful for ES5 environment? Is it 6to5 that creates this only case?

That said, I could live with current implementation, but I think it can be better.

@sheerun

This comment has been minimized.

Copy link

@sheerun sheerun commented Nov 24, 2014

I think by introducing this ad-hoc rule you're damning multiple export for next 5-8 years. Nobody is going to use it because it makes root object be exported under default key.

If you allowed multiple export, but made it usable by ES5 (by assigning to default export), it can be used even now. Yes, exported root object is going to be polluted with extra exports, but only until transcompiled environment. In future, in native environment, it'll be cleanly separated, without changing anything.

@guybedford

This comment has been minimized.

Copy link
Contributor

@guybedford guybedford commented Nov 24, 2014

@sheerun this does not do that at all - it gives users choice.

Perhaps it is easiest to show some examples of the new output format:

// caseA
export var p = 42;
// ->
exports.p = 42;

// caseB
export default fn() {};
// ->
module.exports = function fn() {};

// caseC
export var p = 42;
export default function fn() {};
// ->
exports.p = 42;
exports.default = function fn() {};

The thing to note is that the above ad-hoc rule does not affect how we load modules because the import statements are converted to pick up from the default if it is provided:

import {p} from './caseA';
// ->
var p = require('./caseA').p;

import p from './caseB';
// ->
var p = require('./caseB').default || require('./caseB'); // works

import p from './caseC';
// ->
var p = require('./caseC').default || require('./caseC'); // works too

So we're all good in all situations.

it is important to be able to convert ES6 to CommonJS because it is important to be able to author in ES6.

@sheerun

This comment has been minimized.

Copy link

@sheerun sheerun commented Nov 24, 2014

@guybedford I don't deny that it's important to be able to convert to CommonJS. I'm denying that var p = require('./caseB').default || require('./caseB'); should be required when consuming transcompiled ES6 module from non-transcompiled CommonJS module (pure JavaScript module).

What is wrong with following?

// caseA
export var p = 42;
// ->
var p = 42;
module.exports.p = p;

// caseB
export default fn() {};
// ->
module.exports = function fn() {};

// caseC
export var p = 42;
export default function fn() {};
// ->
var p = 42;
module.exports = function fn() {};
module.exports.p = p;

And:

import {p} from './caseA';
// ->
var p = require('./caseA').p;

import {p} from './caseA';
import fn from './caseB';
// ->
var p = require('./caseB').p;
var fn = require('./caseB');

import {p} from './caseC';
import fn from './caseC';
// ->
var p = require('./caseC').p;
var fn = require('./caseC'); // works too

You see how semantics of export var and export default as well as import {p} and import fn are consistent? And I can easily use transcompiled ES6 from pure CommonJS module.

@briandipalma

This comment has been minimized.

Copy link

@briandipalma briandipalma commented Nov 24, 2014

Upgrading the CJS code to ES6 becomes more involved. The CJS code isn't importing an ES6 module's default export it's exporting a CJS default export. Those are two different things meaning that when you upgrade to ES6 in your code you will then have a potential code change step in the rest of your module. While if you import an ES6 module with ES6 module semantics you have no work to carry out in your module body.

@eventualbuddha

This comment has been minimized.

Copy link
Contributor

@eventualbuddha eventualbuddha commented Jan 22, 2015

It's been fun, but I recommend using either 6to5, which has a CommonJS interop mode that I've found works reasonably well in practice, or http://esperantojs.org/, which is very fast and has a bundler similar to this project.

gutenye referenced this issue in angular/angular Sep 4, 2015
lovasoa added a commit to lovasoa/react-contenteditable that referenced this issue Jan 19, 2016
Using this module in node previously required a
`var ContentEditable = require("react-contenteditable").default`
Now, importing the module is done with
`var ContentEditable = require("react-contenteditable")`

This commit BREAKS COMPATIBILITY (thus, there was a version bump)

Fixes #12
Thanks to @sebasgarcep
See esnext/es6-module-transpiler#85 for more info
@yofreke yofreke mentioned this issue Aug 23, 2016
3 of 3 tasks complete
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.
Projects
None yet
Linked pull requests

Successfully merging a pull request may close this issue.

None yet
You can’t perform that action at this time.