Local modules
Modules let a team maintain a shared policy or helper without copying it into every script. Imports are local files; Zy does not download packages.
In policy.zy:
let office = 10.20.0.0/16
export fn allows(address) {
return address in office
}
In main.zy, alongside policy.zy:
import "./policy.zy" as policy
print(policy.allows(10.20.64.25))
Run the bundled example:
go run . check examples/modules/main.zy
go run . examples/modules/main.zy
It prints:
Office network: 10.20.0.0/16
10.20.64.25 true
192.168.1.10 false
Resolution and evaluation
- Use
import "./file.zy" as aliasorimport "../shared/file.zy" as alias. Paths are string literals with forward slashes and an explicit.zyextension. Absolute paths, bare package names, and dynamic imports are unsupported. - Paths resolve relative to the importing file, independently of the terminal’s working directory. Symbolic links resolve to their physical target before resolving dependencies. Hard-link aliases share the first encountered file’s module and resolution base.
- Imports and exports belong at file scope. Only
export letandexport fnare supported. Import aliases and exported declarations must have unique names among the file’s top-level declarations. - Every dependency is read and parsed before the first statement executes. Missing files, invalid dependency syntax, cycles, and graph-limit violations fail preflight. Source snapshots are then used for execution.
- Top-level module statements run once per execution, when the first import is evaluated, in source order. Multiple aliases share the same module instance. A new interpreter run gets a fresh module cache.
- A module has its own lexical scope and built-in namespace objects. It cannot see the importing script’s variables. Functions and closures retain their defining scope and source location for errors.
check walks this same dependency graph without running top-level statements or functions. It checks known type contracts, record fields, and explicit imported exports. Dynamic values and computed accesses still require execution to validate. Records and enums can also be exported and used as module.Type annotations.
Exports and state
Unexported bindings are private. Read an export with alias.name; indexing or assigning through the module namespace is unsupported. type(alias) returns Module; equality compares module identity.
Exported bindings are live: if a function updates an exported counter, readers observe the new value. Exported lists/maps retain their usual mutable reference semantics. Namespace protection does not freeze those values. Prefer exported functions for policies and keep mutable implementation state private.
Importing a module executes its top-level code. Keep reusable policy modules free of I/O where possible. Modules share the execution deadline, call/evaluation limits, input stream, output writer, and script arguments. io.read_text() and json.load() still resolve relative to the process working directory; importing a module does not change the working directory.
Bounds and compatibility
The graph includes the entry script and permits at most 128 distinct files, 32 files along a dependency path, and 8 MiB of combined source. Each file is limited to 1 MiB by the CLI/loader. Aliases of the same physical file count once. These limits apply to both run and check.
import, export, and as are reserved keywords. Rename existing identifiers using those words. Project manifests and locked local packages are supported. There is no registry, wildcard import, re-export syntax, or cyclic initialization. General local imports allow ../; installed dependency snapshots must keep their imports inside their package.
See the module decision and tooling guide.