EDIT There is a second version of this proposal down in the comments. https://gist.github.com/445adc206d51a038de4a#gistcomment-359858
I really like Isaac's module proposal. Having recently designed a module system for the luvit project (node ported to lua), I've thought a bit about this.
I think we can set a few minimal rules and reduce a ton of edge cases. One in particular is cyclic dependencies. I don't think they are worth the pain and should simply be disallowed.
To help understand things, we should make a distinction between dependencies and peers. In a large application or package there are several files containing code. Some files depend on things defined in other files before they can start. Simple dependencies that block startup can't be cyclic or you'll have a deadlock at startup. So the other type of dependency is wanting to reference an api or function from another file during runtime.
The basic syntax I propose is like the one Isaac showed (if I understand it correctly). There are two new keywords, import
and export
. The import
keyword must be followed by a string literal that is the identifier for the file you want to import. The export
keyword is like return
and yield
in that it sends a value. It does not affect control-flow, but it does set the current file's export value.
Consider lib/add.js
:
// lib/add.js
function add(a, b) {
return a + b;
}
export add;
And then we have a peer file that depends on this:
// lib/usesAdd.js
var add = import "./add.js";
var result = add(2, 3);
Now suppose we wanted to write a math package that contains add as well as other functions.
// math.js
export {
add: import "./lib/add.js",
multiply: import "./lib/multiply.js",
// ...
};
Here is where this gets tricky. Suppose that we want to implement multiplication using addition and a loop. So our multiplication library will need to depend on add. I can't depend on the main app because that depends on it. So it has to depend on add directly.
// lib/multiply.js
var add = import "./add.js";
function multiply(a, b);
var product = 0;
for (var i = 0; i < b; i++) {
product = add(product, a);
}
return product;
}
But not we learn later on that add needs to access multiply for some reason. This happens all the time in real applications. How would this be required?
I really want dependencies with import and export to be expressible as a DAG (Directed Acyclic Graph). There is a clear order for loading the executing the files. There are two reasons for this. One, it makes a lot of nasty edge cases with current module systems simply go away. And second, JavaScript is single threaded. Even with the exports.foo = bar
syntax in node and CommonJS, the dependencies have to be executed in some serial order. This may surprise people when you're allowed to require some module, but it's methods aren't populated yet because you were executed before it was. This would be a leaky abstraction.
So then how would I solve this dilemma using acyclic dependencies? I would handle it at the application level where I control the logic and know what to expect.
I would rewrite math.js
as follows:
// math.js
var math = {};
export math;
math.add = (import "./lib/add.js")(math);
math.multiply = (import "./lib/multiply.js")(math);
Then in each module I would export a function that accepts the math
namespace.
// lib/add.js
export function (math) {
return function (a, b) {
// ... might use math.multiply
};
};
There are several other techniques that come to mind that I could employ for other use cases. The point is that using the basic primitives that would be provided by the language, I can organize my app however I want. My code would use the same style in the browser or in node. The browser could pull my code on demand as it's needed or a server-side compiler could statically analyze the imports and generate a single js file that the browser then reads. All JS code everywhere would be written using the same simple module syntax, but without imposing huge constraints on the runtime or providing leaky abstractions to applications.
I would like to attempt to illustrate how @dherman’s proposal might handle mutual dependencies, in its defense. As @johnjbarton points out, Traceur hasn’t done this yet, so this is my speculation.
Mutually dependent modules would get transpiled into a “working set” with a shared scope. Imports and exports would be hoisted to this shared scope.
odd.js
even.js
main.js
transpiled.js
Consider an alternative with single-value exports/imports. I've removed the names of the exported functions to clarify that the name does not apply to the exported symbol, but if they were retained, they would simply be the name of the function.
odd.js
even.js
main.js
The transpiled form would be identical to the previous example.
Note that this cannot be done with
let
orvar
, because the declaration must hoist to a different scope.This alternative impacts destructuring multiple exports.
With @dherman's proposal, each individual exported name populates a variable in shared scope. Formally ignoring nested module name-spaces for a moment, destructuring in an
import
clause does not occur at the site of theimport
clause, but at the site of the correspondingexport
.mathy.js
main.js
Note that there is no destructuring assignment in the transpiled output. The destructuring occurs when the working set of modules is compiled, and each of the variables in the structure is mapped to a variable in the shared scope.
Consider the equivalent with single-value exports. This example applies to both
return
orexport
syntax.mathy.js
main.js
transpiled.js
The rules are slightly different. With single-value exports, we must execute destructuring for each corresponding import at the location of the export.
In both cases, the shared values stabilize after all involved factories have finished executing. Also noteworthy that single-value-exports does not need knowledge at compile time of the shape of the exports object. It also does not need to track at compile-time the boundary between static members and dynamic members.
Left as a future exercise, imports that are commuted to exports. These probably do not work so well with single-value-exports, but fine with multi-value-exports.