Package Scope Proposal
A proposal for restricting imports within ES Modules related to a new concept called packages. It allows to expose
items which later could be import
-ed not by all JS Modules, but according to package hierarchy.
Motivation
Within a JS codebase based on ES modules, it's possible to import
everything which is export
-ed. On one hand it's expected, but on the other hand, in big codebases (e.g. large business applications, but also complex libraries) there's no native way to restrict which part of the project is able to depend on another one. And introducing a certain tool (which delivers such solution) in not always possible.
In different words, if a JS module is implemented as a regular text file, the ecosystem lacks an entity that would be a higher abstraction over a module that doesn't necessarily have to be installed via a package manager (and e.g. be loaded from node_modules
).
Moreover, lack of encapsulation is a never-ending, re-occurring issue in many JS-based projects. This proposal aims to improve this area.
One more additional benefit of this proposal is that, due to restricting possible imports relying on package hierarchy, visualizing project structure and dependency hierarchy would get simpler, as packages would be considered in the first place, not just imports (if packages would get commonly used, imports would be restricted to packages anyway).
Proposal
This proposal introduces package
s - a hierarchy of files and directories logically connected to each other. All JS Modules belong to a package (if it's not explicitly stated, they belong to the default
package - see Backwards compatibility section). Each JS Module can be explicitly labelled as belonging to a package defined by a slash-separated string name.
The hierarchy of packages consists of following terms:
- sub-package - a lesser item within the hierarchy
- super-package - inversion of sub-package
- own package - the same package (file A want to import from file B, both within the same package, so they import from their own package)
- unrelated package - different packages where neither is a sub-package of the other
Members of a package expose
what can be import
-ed. The new expose
keyword works similarly to import
, but throws an error when trying to import something that belongs to neither this exact package or a its sub-package. Following rules apply:
- if an item is
export
-ed (ES2015), it can beimport
-ed everywhere, no matter the package hierarchy - if an item is
expose
-d (this proposal), it can beimport
-ed only within its own package or its super-packages (all the way up the hierarchy). Explicitly, it cannot be imported in sub-packages or unrelated packages.
Example
Given the following directory/file structure:
src/
app/
components/
...
index.js
...
lib/
sync.js // -> labelled as "lib" package
events/
broker.js // -> labelled as "lib/events" package
index.js
pubsub.js // -> labelled as "lib/events" package
__tests__/
broker.spec.js
pubsub.spec.js
state/
message.js
store.js // -> labelled as "lib/state" package
...
Certain JS modules can be labelled as belonging to a package or not (then it remains in the default
package). Example package hierarchy could reflect directory structure:
lib
lib/events
lib/state
app
...
but it doesn't have to (if needed, package structure and directory structure can deviate).
Within the above structure:
lib
is the super-package to bothlib/events
and/lib/state
- both
lib/events
and/lib/state
are sub-packages tolib
lib/events
and/lib/state
are unrelated packages to each other- files
src/lib/events/broker.js
andsrc/lib/events/pubsub.js
live in their own package,lib/events
(e.g. when oneimport
-s from the other)
Following files expose
:
src/lib/sync.js
exposesfunction sync(){}
src/lib/events/broker.js
exposesconst broker = {}
src/lib/state/store.js
exposesconst store = {}
In above hierarchy:
- file
src/lib/sync.js
canimport { broker } "./events/broker.js"
(import from sub-package is allowed) - file
src/lib/events/broker.js
cannotimport { sync } from "../sync"
(import from super-package is NOT allowed) - file
src/lib/events/broker.js
cannotimport { store } from "../state/store"
(import from an unrelated package is NOT allowed)
Importing from packages/sub-packages relies solely on package hierarchy, not directory/file structure (although it would be handy to keep them in sync anyway, but that's beyond the proposal).
Syntax
// file: src/lib/events/broker.js
package "lib/events";
const symbol = Symbol('BROKER') // cannot be imported
expose const broker = {} // can be imported only from "lib/events" or its super-packages
export function notifyBroker(){} // can be imported everywhere - as it is today
Labelling a JS Module as belonging to a package is the first line of the module (for the sake of readability).
Two new keywords are introduced: package
and expose
. The expose
keyword is not available within the default package (or lack of explicit package
statement), as it doesn't make sense.
Packages form a hierarchy in a similar way as directories - through the /
char. This way:
lib
is the the super-package oflib/events
lib/events
is the the sub-package oflib
lib/events
andlib/state
are unrelated packages.
Scopes and Visibility
expose
vs export
expose
-d items are importable within its own package or its super-packagesexport
-ed items are importable in all packages
Object Properties
This proposal doesn't interfere with private properties/methods/... (#
) in any way. It relates only to module exports.
Backwards compatibility
Each and every module belongs to a package (or sub-package). To guarantee backwards compatibility without any breaking changes, the default
package is introduced:
package "default";
// some code ...
is equivalent to:
// some code ...
The contents of the default
package cannot be expose
-d. Hence, they are importable according to what is export
-ed and what is not.
compatibility with existing npm packages
The export
keyword (or module.exports
) works as before - items can be imported within all packages.
The expose
-d items cannot be imported.
Prior Art & Userland implementations
Maintainers of big codebases struggle to achieve a way to disallow unwanted imports. Currently the following solutions have been identified to serve a similar purpose:
ESLint's no-restricted-imports
example:
"no-restricted-imports": ["error", {
"paths": ["import1", "import2"],
"patterns": ["import1/private/*", "import2/*", "!import2/good"]
}]
Despite additional tooling, this lint is:
- quite limited - not hierarchical (!), each pattern/path has to be explicitly declared, etc.
- centralized - cumbersome to maintain in big codebases, as the rules are separate from the code itself. All ESLint rules are stored together far away from the code itself. Refactors (e.g. moving files/directories) require additionally updating the global settings of the linter rule. Defining restricted imports within the code would be way more convenient in daily work.
NX Module Boundaries
example:
"@nx/enforce-module-boundaries": [
"error",
{
"allow": [],
// update depConstraints based on your tags
"depConstraints": [
{
"sourceTag": "scope:shared",
"onlyDependOnLibsWithTags": ["scope:shared"]
},
{
"sourceTag": "scope:admin",
"onlyDependOnLibsWithTags": ["scope:shared", "scope:admin"]
},
{
"sourceTag": "scope:client",
"onlyDependOnLibsWithTags": ["scope:shared", "scope:client"]
}
]
}
]
- in some way, NX Module Boundaries influenced this proposal
- centralized (same cons as above)
- limitation: many codebases cannot be turned into a Nx-specific monorepo only for the purpose of using the mentioned feature. Native support would be available for all.
Java Packages
- in some way, java's package-private scope influenced this proposal