Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

"Magic parallel scope" alternative: import.parent #27

Open
nicolo-ribaudo opened this issue Apr 21, 2023 · 8 comments
Open

"Magic parallel scope" alternative: import.parent #27

nicolo-ribaudo opened this issue Apr 21, 2023 · 8 comments

Comments

@nicolo-ribaudo
Copy link
Member

nicolo-ribaudo commented Apr 21, 2023

Module declarations/expressions right now capture bindings of other module declarations:

let a = 1;
module A {}

module B {
  a; // ReferenceError
  import A; // works, module declarations are captured
}

When advancing this proposal to Stage 2, different concerns have been raised due to the different scoping behavior of this new binding type.

Solution

While discussing with @lucacasonato about this problem, we came up with a possible solution:

  • Module declarations bindings behave like other bindings: they are not captured by other module declarations
  • We introduce a new import.parent meta property, that:
    • In file-level modules is null
    • In module declarations/expressions, is the Module object that syntactically encloses the current module
  • We introduce the new import ... from import.parent; to import from the parent module.

Drawbacks

  • Module declarations need to be exported to expose them to children modules:

    module A {}
    export module B {}
    
    module C {
      import { A } from import.parent; // Doesn't work
      import { B } from import.parent; // ok
    }
  • It's not possible to directly import from ancestors other than the parent, unless they re explicitly re-exported by the parent module (for example, using the import reflection proposal).
    Currently module declarations allow doing this:

    export module A {}
    export module B {
      module C {
        import A;
      }
    }

    and it would need to be re-written to this:

    export module A {}
    export module B {
      export { A } from import.parent; // explicitly re-export
    
      module C {
        import { A } from import.parent;
        import A;
      }
    }

    However, the vast majority of use cases for module declarations is with no nesting, so this shouldn't hurt usability much in practice. Additionally, it simplifies refactoring because you only have to pay attention to import.parent rather than to all the module bindings higher in the scope chain.

Example

Consider this example with the current proposal:

export module A {
  export let a = 1;
}
export module B {
  import { a } from A;
  export let b = a * 2;
}

it would be rewritten as follows:

export module A {
  export let a = 1;
}
export module B {
  import { A } from import.parent;
  import { a } from A;
  export let b = a * 2;
}

FAQ

  • Why import.parent?
    We considered different alternatives, such as just a parent identifier (with a restriction that prevents module declarations from being named parent), or super (proposed in How to import from the parent module? #20). However, the meta-property-based syntax has the advantage that it can also work in dynamic imports:

    export let x = 1;
    module A {
      import { x } from import.parent;
      console.log(x);
    }
    module B {
      const { x } = await import(import.parent);
      console.log(x);
    }
  • How does this interact with the Module constructor?
    Compartments Layer 0 expands the Module constructor so that it can be used to customize the linking behavior of modules:

    new Module(source, {
      importHook(specifier) { ... }
    });

    Integrating the module declarations proposal with such constructor was incredibly challenging, because we needed a static way of representing the module declarations captured by the outer scope. Something like the following:

    new Module(source, {
      importHook(specifier) { ... },
      capturedStaticModules: {
        A: new Module(...)
      }
    });

    and A would have been magically injected as visible in the constructed module's scope.

    With this import.parent simplification, the Module constructor could simply accept an optional parentModule property, whose value is then exposed as import.meta without affecting the visible bindings:

    new Module(source, {
      importHook(specifier) { ... },
      parentModule: parent,
    });
@mhofman
Copy link
Member

mhofman commented Apr 21, 2023

Interesting suggestion. Raw train of thought:

  • From what I understand of the proposal, the things importable by the nested module are simply exported bindings from the parent module.
  • This would apply to module declarations as well as expressions, right? As such, is there any difference left between:
    const A = module { export let a = 1; };
    And
    export module A { export let a = 1; }
  • Or more specifically, can you rewrite the example as:
    export const A = module {
      export let a = 1;
    };
    export const B = module {
      import { A } from import.parent;
      import { a } from A;
      export let b = a * 2;
    };
  • In the example provided for the Module constructor, the difference between capturedStaticModules and parentModule seem to be mostly that the previous capturedStaticModules was roughly the evaluated version of parentModule (aka it's "Module Namespace Exotic Object"). Since this proposal effectively introduces an explicit level of indirection to access the parent's importable bindings, we no longer need to provide these directly in the Module constructor.

While writing this I have been tempted to suggest hiding the import foo from import.parent under the hood, and simply say that any import x from foo where foo is not another explicitly imported binding, then foo is implicitly imported from the parent module without needing the import.parent meta property. However there would be no equivalent for dynamic import as pointed out, and it would burry what is actually happening.

import.parent seemed awkward at first, but it's a really elegant solution to unify module declarations and expressions. I like it!

@ljharb
Copy link
Member

ljharb commented Apr 21, 2023

The word “parent” might be confusing; node’s CJS has module.parent and it refers to whoever first required the module. Lexical scoping isn’t really a parent-child relationship.

@mhofman
Copy link
Member

mhofman commented Apr 21, 2023

Name bikeshed: import.scope?

@ljharb
Copy link
Member

ljharb commented Apr 21, 2023

Separate from bikeshedding, it’s very unideal to be forced to expose something in my API just so i can use it inside a module declaration/block - is there a reason things can’t be explicitly referenced in the syntax? like:

module A {}
module B {}
module C with A, B { }

@tjjfvi
Copy link

tjjfvi commented Aug 23, 2023

it’s very unideal to be forced to expose something in my API just so i can use it inside a module declaration/block

One could always use an inner module to enforce that boundary; i.e.

export const foo = ...;
module A {}
module B {}
module C with A, B {}

could be written as

module inner {
  export const foo = ...;

  export module A {}
  export module B {}
  module C {
    import { A, B } from import.parent;
  }
}
export { foo } from inner;

@tjjfvi

This comment was marked as duplicate.

@Josh-Cena
Copy link

Bikeshed: import ... from super;?

It's already mentioned in the OP. The problem is that in order for this to work in dynamic imports we have to make super itself a primary expression, or import(super) itself to be a special construct, and neither sounds ideal.

@lucacasonato
Copy link
Member

import.super would be an option.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

No branches or pull requests

6 participants