Modules and Imports
Canonical source:
docs/guide/06-modules-and-imports.mdat locked revisione99b712ecf658a527b1fe82c8beba2c1286d8dc9. This rendered page is non-normative; the applicable Protos specification remains authoritative.
Status: Non-normative programming guide
Normative owners:
spec/semantics/MODULES.md,spec/semantics/EXECUTION_AND_CONTROL.md, andspec/PROTOS_GRAMMAR.md
The previous chapters showed that Protos tries to reuse the same mechanisms rather than creating parallel language subsystems. Modules follow that rule too.
The central idea is:
A module is an ordinary Actor-local execution context with a canonical loader identity, and
import(...)is an ordinary eager operation that returns that context object.
There is no separate global-variable universe, namespace wrapper, static import declaration, export table, or second object model for modules.
Top-level bindings are module-context slots
Section titled “Top-level bindings are module-context slots”Every module executes inside a moduleContext.
A top-level binding:
version: "0.1"
describe: () => { "Protos " + version}creates local slots on that module context:
moduleContext├── version└── describeThis is the same slot mechanism used by other execution contexts.
Closures created while the module executes capture the module context through ordinary lexical capture. There is no separate rule for “global” variables.
Each module gets its own context
Section titled “Each module gets its own context”Two different modules do not implicitly share mutable module-level state.
If module counter contains:
value: 0
next: () => { value = value + 1 value}then value belongs to that module instance’s own context.
Another module can observe the state only by obtaining that module instance and accessing its slots.
import(...) is an ordinary call
Section titled “import(...) is an ordinary call”Protos does not add dedicated import-declaration grammar.
You write:
math: import("some-module")and import(...) returns a module instance.
That result is just another Protos object value from the caller’s point of view, so cross-module access uses ordinary member lookup:
math.sin(x)math.constantsThere is no syntax such as:
import x from "..."export xin Core v0.1.
There is no separate export mechanism
Section titled “There is no separate export mechanism”A module instance is the module’s own moduleContext.
Therefore a top-level binding is directly observable as a slot of the imported module instance:
settings: { mode: "safe"}
run: () => { ...}After importing that module:
m: import("...")m.settingsm.run()uses ordinary slot/member lookup.
Core does not build a second export table, namespace object, proxy, or copy.
This makes module boundaries explicit but also means that a top-level helper binding is not automatically private merely because the programmer did not write an export declaration: no such declaration exists.
Keep implementation-private state below the module surface
Section titled “Keep implementation-private state below the module surface”If state should not be a top-level module slot, put it inside another lexical scope and expose only the intended object or Closure.
For example:
counter: (() => { value: 0
({ next: () => { value = value + 1 value } })})()Here counter is a module-level slot, while value belongs to the invoked
Closure’s lexical context and is reached by the returned next Closure through
ordinary capture.
The useful mental rule is:
Top-level means module-visible. Lexically nested state remains governed by ordinary lexical reachability.
Import accepts exactly a semantic String
Section titled “Import accepts exactly a semantic String”The specifier argument to standard import must be a semantic String value.
This is valid:
import("std:collections/Array")An arbitrary object does not become a module specifier merely because it looks String-like or defines conversion behavior.
Core performs no implicit:
toStringformattingpath conversionURI conversioninvocationcoercionbefore module resolution.
Invalid specifier values signal Error before the host resolver is invoked.
The exact String goes to the resolver
Section titled “The exact String goes to the resolver”Core deliberately does not assign intrinsic path or package meaning to the specifier text.
The String:
"./model"is just an exact String at the Core boundary.
Core does not itself:
- append a source extension;
- normalize path separators;
- resolve
.or..; - case-fold;
- absolutize;
- treat it as a URL;
- treat it as a package;
- treat it as a Standard Library name.
Those are resolver/host policy decisions.
This separation is important because the same Core module lifecycle can work with filesystem modules, bundled Standard Library modules, package modules, or other host-defined sources without changing the language model.
Direct-file execution supplies a local module namespace
Section titled “Direct-file execution supplies a local module namespace”The official direct-file and debug-file CLI hosts add one concrete resolver
domain without changing the Core meaning of import.
Given:
app/├── main.protos├── helper.protos└── lib/ └── parser.protosrunning:
protos app/main.protosallows explicit local module specifiers:
Helper: import("./helper.protos")Parser: import("./lib/parser.protos")./ and ../ are interpreted relative to the importing local source, not
relative to the process working directory.
So if lib/parser.protos contains:
Helper: import("../helper.protos")the import resolves inside the same selected local source tree.
The direct entry’s containing directory is the initial local-source root. A
normalized import may move upward with .. only while it remains inside that
tree:
main.protos -> ./lib/parser.protos allowedlib/parser.protos -> ../helper.protos allowedmain.protos -> ../outside.protos ErrorThe initial direct-file policy is intentionally exact:
/is the portable local-module separator;- no process-CWD fallback exists;
- no
.protosextension is appended implicitly; - no directory/index or search-path probing occurs;
- component names must match exactly, including case;
- imported symbolic-link/reparse traversal is not followed;
- a local source-loading namespace does not grant a general guest
Filesystemcapability.
The direct entry itself has a canonical module identity before its first source expression executes. Therefore an ordinary cycle:
main -> helper -> mainreturns the already-existing partially initialized main module instance rather
than executing the entry a second time.
protos debug <file> uses the same local module namespace and identity rules as
ordinary direct-file execution.
Locationless execution does not invent a filesystem base. -e, the REPL, stdin,
and generated/in-memory sources have no implicit ./ local namespace and do not
fall back to the current working directory.
This direct-file domain does not mean that every import is a relative path. Other resolver domains remain distinct:
Std: import("std:collections/Array")Package execution continues to own self: and dep:. Absolute-file, file:,
network/URL, Git, wider/multiple source-root, and similar domains remain possible
future resolver additions rather than alternate meanings of import.
Specifier, ModuleKey, and module instance are different concepts
Section titled “Specifier, ModuleKey, and module instance are different concepts”Module loading has three distinct layers:
specifier String | | host/module resolution vcanonical ModuleKey | | Actor-local cache vmodule instanceThe specifier is the exact String supplied by the program.
The ModuleKey is the canonical identity produced by the resolver.
The module instance is the Actor-local Protos object returned by import.
Keeping these concepts separate explains several otherwise surprising results.
Different spellings may identify the same module
Section titled “Different spellings may identify the same module”A resolver may decide that two specifier Strings refer to one canonical module.
Conceptually:
a: import("a")b: import("alias-a")can satisfy:
a === bwhen both specifiers resolve to the same canonical ModuleKey in the same
Actor.
The cache is keyed by the canonical identity, not by the original spelling.
So “same import String” is not the definition of module identity. The resolved
ModuleKey is.
A module instance is an ordinary identity-bearing object
Section titled “A module instance is an ordinary identity-bearing object”The module instance is the actual module context object.
Within one Actor, repeated imports of the same canonical ModuleKey return the
same active cached module instance:
a: import("foo")b: import("foo")
a === bis true while they refer to that same active cached instance.
There is no wrapper object whose identity differs from the module context.
This also means that ordinary member reads observe the module’s actual current slot state.
Module instances are Actor-local
Section titled “Module instances are Actor-local”The cache belongs to an Actor.
Conceptually:
canonical foo | +------ Actor A ------> foo@A | +------ Actor B ------> foo@Bfoo@A and foo@B are different mutable module instances.
An Actor does not inherit another Actor’s mutable module cache or module contexts.
An implementation may share immutable compiled artifacts internally, but that must not become shared mutable Protos module state.
This is the module-level consequence of Actor isolation.
Import is eager by default
Section titled “Import is eager by default”Calling:
m: import("foo")loads/initializes the module when required before the call completes normally.
Core does not make dependency loading implicitly lazy.
If lazy dependency acquisition is desired, use an ordinary language mechanism, for example a Closure:
loadFoo: () => { import("foo")}The laziness then comes from when loadFoo is invoked, not from a special
lazy-import rule.
Cache before execute
Section titled “Cache before execute”When a canonical module is absent from an Actor’s cache, the conceptual order is:
1. create module instance / moduleContext2. cache it as INITIALIZING3. execute the module body4. mark it READY on successThe important invariant is:
The new module is cached before its body executes.
This is what makes cyclic imports possible without recursively creating an unbounded sequence of new module objects.
Cyclic imports are valid
Section titled “Cyclic imports are valid”Suppose A imports B and B imports A:
A -> B -> AThe sequence is conceptually:
import A create A cache A / INITIALIZING execute A import B create B cache B / INITIALIZING execute B import A return cached AThe second import of A returns the already-existing INITIALIZING A instance.
It does not create A again.
A partially initialized module is the real module
Section titled “A partially initialized module is the real module”During a cycle, another module can observe a module before its body has finished.
If A executes:
x: 10b: import("b")y: 20and B imports A during that import("b"), B receives the real current A module
instance.
At that moment:
A.x existsA.y does not exist yetThere is no module-specific temporal dead zone, no predeclared future slot, and no hidden “uninitialized binding” value.
Trying to read a not-yet-created slot follows the ordinary missing-slot Error rules.
Recursive import does not wait for READY
Section titled “Recursive import does not wait for READY”When a cycle finds an INITIALIZING instance, import returns it immediately.
It does not secretly wait for the module to become READY, because ordinary
same-Actor cyclic initialization could deadlock if each side waited for the
other to finish.
Core module resolution and recursive import do not introduce an implicit suspension/reentrancy point.
Explicit suspension facilities remain explicit.
Successful initialization keeps the same identity
Section titled “Successful initialization keeps the same identity”When the module body completes normally:
INITIALIZING -> READYThe object is not replaced.
The same module instance remains cached, so later imports return it without re-executing the body.
Initialization state changes; semantic object identity does not.
Failed initialization is removed from the active cache
Section titled “Failed initialization is removed from the active cache”If initialization terminates with an unhandled Error:
create foo#1cache foo#1 / INITIALIZINGexecute foo#1 Errorremove foo#1 from cacheimport failsThe failed attempt does not remain as the successfully importable active module.
A later import may retry:
create foo#2cache foo#2 / INITIALIZING...READYand foo#2 is a fresh module instance.
This prevents one failed attempt from permanently poisoning the Actor’s module cache.
Failure does not roll back completed effects
Section titled “Failure does not roll back completed effects”Removing a failed module from the cache does not undo time.
If initialization already changed ordinary reachable state or exposed a reference before failing, those completed effects are not automatically rolled back.
An escaped reference to the failed partial instance remains an ordinary object.
Therefore it is possible for:
foo#1 failed partial instance, still reachablefoo#2 later active READY instanceto coexist in one Actor, with:
foo#1 !== foo#2Only foo#2 is the active cached instance for that ModuleKey.
Import failure maps to Protos Error
Section titled “Import failure maps to Protos Error”Resolver failures and source-loading/initialization failures cross the standard language boundary as Protos Error behavior.
Portable code should not depend on raw host exceptions, filesystem exception classes, HTTP status values, or similar host-specific failure objects unless a separate normative host/domain API explicitly exposes them.
This keeps the module mechanism portable even when resolver implementations are host-specific.
The initial module follows the same context model
Section titled “The initial module follows the same context model”The program entry module also executes in an Actor-local module context.
When that initial module has an importable canonical identity, it participates in the same cache-before-execute lifecycle before its first source expression runs.
That matters for cycles such as:
main imports bb imports mainbecause b must obtain the already-existing initial main instance rather than
causing a second main instance to be created.
A standalone entry may have no ModuleKey
Section titled “A standalone entry may have no ModuleKey”Core does not require every possible host-launched source to be assigned an invented import identity.
An initial entry that is not importable through the resolver may execute as a standalone module context:
Actor-local moduleContextno ModuleKeynot in import cacheIt is not later retroactively adopted into the module cache.
If equivalent code later becomes importable under a canonical key, a subsequent import creates the ordinary cached module instance; that new instance is distinct from the earlier standalone entry object.
Prelude facilities are shared only under immutable semantics
Section titled “Prelude facilities are shared only under immutable semantics”Universal language facilities may be supplied through the standard prelude.
The prelude is frozen. A module can read a prelude binding:
ObjectArrayimportbut ordinary unqualified assignment must not mutate a binding found only in the frozen prelude.
A module can deliberately shadow a prelude name by creating a module-local slot:
Array: myArrayFactoryThis follows the ordinary binding rules from chapter 1.
It is not a special module override mechanism.
std: is resolver policy, not Core import syntax
Section titled “std: is resolver policy, not Core import syntax”The current Protos distribution supplies Standard Library modules through specifier spellings such as:
Arrays: import("std:collections/Array")Sets: import("std:collections/Set")Those are useful current runnable spellings.
But the important conceptual boundary is:
import(...) Core ordinary facility
"std:collections/Array" String interpreted by the selected Standard Library resolverThe std: prefix is not a reserved language token and does not change
import(...) grammar.
Likewise, other environments may support their own resolver domains without changing Core module lifecycle semantics.
Package resolver spellings remain a resolver concern
Section titled “Package resolver spellings remain a resolver concern”Current package-tool work uses resolver domains such as self: for package
workspace modules.
For example, package-tool source contains calls such as:
MetadataPublication: import("self:MetadataPublication")That demonstrates the same Core operation with a different resolver policy.
Do not infer from such spellings that Core v0.1 defines package syntax,
filesystem semantics, or an import self language construct.
Package acquisition, manifests, dependency identities, and the bundled toolchain belong to their own project/library layer and are covered separately.
Host-supplied domains: app: and foreign providers
Section titled “Host-supplied domains: app: and foreign providers”Two further resolver domains exist only when a host sets them up. Both reuse
the same import(...) and the same module lifecycle described above.
app: application modules. A Java host embedding Protos through a
standard Polyglot Context can install a catalog of in-memory modules:
Worker: import("app:worker")The exact app: String is the canonical ModuleKey; there is no relative
lookup, alias, extension, or filesystem/classpath search, and supplying code
grants no authority. See
Supplying application modules from Java.
Foreign providers. A host can register providers that own an import scheme and supply modules that are not Protos source:
Math: import("java:math")The result is an Actor-local facade that follows the ordinary cache, cycle, failure, and retry rules; the foreign object never becomes the module identity. Which schemes exist depends entirely on the providers the host registered. See the interoperability map for what is available today.
Practical rules to remember
Section titled “Practical rules to remember”- Treat module top-level bindings as slots of one ordinary
moduleContext. - There is no separate global-variable category.
import(specifier)is an ordinary call and eagerly returns a module instance.- The specifier must be exactly a semantic String; Core does not coerce it.
- Separate specifier spelling, canonical
ModuleKey, and module-instance identity in your mental model. - Same canonical key plus same Actor means the same active cached instance.
- The same canonical key in different Actors means different mutable module instances.
- There is no dedicated export table: top-level module slots are directly observable.
- Cache-before-execute makes cycles legal and exposes the real partial module state reached so far.
- A missing not-yet-created cyclic slot is an ordinary lookup Error, not a special uninitialized value.
- Failed initialization removes the active cache entry and a later retry may create a fresh identity.
- Failure does not roll back already-completed effects or revoke escaped references.
- Treat
std:,self:, filesystem spellings, and package spellings as resolver policy unless a relevant domain specification says otherwise. - Use lexical nesting when implementation state should not be a top-level module slot.
Current runnable examples
Section titled “Current runnable examples”The repository already contains real imports using multiple resolver domains.
Direct-file local import:
../../protos/examples/modules/direct-file-import/main.protosimports the neighboringhelper.protoswith the explicit./helper.protosdirect-file spelling.
Standard Library import:
../../protos/benchmarks/collections/array-map.protosimportsstd:collections/Arrayand invokes the returned module’s ordinarymapslot.
Package-workspace import:
../../protos/tools/package/Main.protosimports package-tool modules such asself:MetadataPublicationandself:BootstrapMessage.
The Standard Library resolver is implementation/distribution evidence for the
current std: policy:
The module runtime tests exercise the Core lifecycle directly:
../../src/test/java/com/guillermomolina/protos/execution/ProtosModuleRuntimeTest.javacovers semantic-String validation, canonical-key aliasing, cache-before-execute, cycles, Actor-local caches, failed-initialization eviction, and retry.
These links demonstrate current behavior. The normative specification remains authoritative.
Normative references
Section titled “Normative references”For exact behavior, consult:
../../spec/semantics/MODULES.mdfor module contexts,import, specifier validation, canonical module identity, Actor-local caching, initialization, cycles, failure, and initial modules;../../spec/semantics/EXECUTION_AND_CONTROL.mdfor execution contexts and lexical behavior used by module contexts;../../spec/PROTOS_GRAMMAR.mdfor the absence of dedicated import/export declarations and the ordinary expression/call syntax used byimport;01-bindings-contexts-and-state.mdfor the binding and execution-context model reused at module top level;03-closures-methods-and-receivers.mdfor lexical capture of module state;05-values-identity-equality-and-collections.mdfor semantic identity and===, which module-instance identity uses.
Those documents define the language. This chapter supplies the programmer-facing model for using modules without importing assumptions from another language’s module system.