Modules
Zym’s module system lets you split code across files and share functionality between them. Modules are cached by default — every import of the same file returns the same instance — giving you singleton-style shared state without any extra work. Modules that need fresh instances per import can opt out with a simple directive.
Overview
A module is any .zym file that returns a value (typically a map of functions). Other files pull it in with import("path"). The module loader resolves paths, detects circular imports, and combines everything into a single compilation unit.
| Feature | Description |
|---|---|
import("path") | Import a module by relative file path |
import name from "path" | Named import — bind the module to a symbol |
| Caching (default) | Module body executes once; all imports share the same instance |
"use fresh" | Directive to opt out of caching — each import gets a new instance |
| Circular detection | The loader detects and reports circular import chains at load time |
Basic Import
Use import("path") to load a module. The path is relative to the importing file. The module file should return a value — typically a map containing the functions and data you want to expose.
func add(a, b) { return a + b } func mul(a, b) { return a * b } return { add: add, mul: mul }
var math = import("math_utils.zym") print(math.add(2, 3)) // 5 print(math.mul(4, 5)) // 20
The import() expression evaluates to whatever the module file returns. You can use dot-access on the result immediately or store it in a variable.
Named Imports
The import name from "path" syntax binds the module to a symbol name. You can then call the module as a function using that name:
import utils from "math_utils.zym" var result = utils() print(result.add(10, 20)) // 30
Module Caching
By default, modules are cached. The module body executes once on the first import, and every subsequent import of the same file returns the exact same value. This means all importers share the same state.
var count = 0 func increment() { count = count + 1 } func getCount() { return count } return { increment: increment, getCount: getCount }
var a = import("counter.zym") var b = import("counter.zym") a.increment() a.increment() print(a.getCount()) // 2 print(b.getCount()) // 2 — same instance!
Both a and b reference the same module instance. When a increments the counter, b sees the change because they share the same internal state.
Modules are singletons by default — the module body runs once, and every subsequent import returns the cached result.
The "use fresh" Directive
If a module needs to provide a new instance on every import, place the "use fresh" directive at the very top of the module file. This opts the module out of caching — each import() call re-executes the module body and returns a fresh value.
"use fresh" var count = 0 func increment() { count = count + 1 } func getCount() { return count } return { increment: increment, getCount: getCount }
var a = import("fresh_counter.zym") var b = import("fresh_counter.zym") a.increment() a.increment() print(a.getCount()) // 2 print(b.getCount()) // 0 — separate instance
The directive must be the first non-whitespace content in the file. It is a string literal, not a keyword.
| Behavior | Default (cached) | "use fresh" |
|---|---|---|
| Module body executes | Once | Every import |
| State sharing | All importers share state | Each importer gets own state |
| Use case | Utility libraries, configs, shared services | Factories, per-component state |
Module Return Values
A module communicates its public API through its return value. The convention is to return a map with named keys:
var level = "info" func setLevel(l) { level = l } func log(msg) { print("[" + level + "] " + msg) } func getLevel() { return level } return { setLevel, log, getLevel }
The caller then uses dot-access on the returned map:
var logger = import("logger.zym") logger.setLevel("warn") logger.log("something happened") // [warn] something happened
You can return any value — a number, string, function, or list — but maps are the most common pattern since they let you expose multiple named exports. The shorthand syntax { name } expands to { name: name }, making exports concise. See Map Shorthand Syntax for details.
Circular Import Detection
The module loader tracks the import chain and detects circular dependencies at load time. If file A imports file B and file B imports file A, the loader reports a clear error showing the cycle:
Circular import detected:
[a.zym]
`--> [b.zym]
`--> [a.zym] <-- ERROR: Already importing (creates cycle)
To fix circular imports, restructure your code so that shared dependencies are extracted into a separate module that both files can import.
Path Resolution
Import paths are resolved relative to the importing file, not the entry script. This lets you organize modules in subdirectories without worrying about the working directory.
project/
main.zym
lib/
helpers.zym
math/
vectors.zym
var helpers = import("lib/helpers.zym") var vectors = import("lib/math/vectors.zym")
// Relative to this file, not main.zym var vectors = import("math/vectors.zym")
Common Patterns
Singleton Service
The default caching behavior makes modules ideal for shared services — configuration, logging, state management:
var settings = { debug: false, maxRetries: 3 } func get(key) { return settings[key] } func set(key, value) { settings[key] = value } return { get, set }
Every file that imports config.zym shares the same settings map.
Factory Module
Use "use fresh" when each importer needs independent state:
"use fresh" var state = {} func setState(key, value) { state[key] = value } func getState(key) { return state[key] } func render() { print(state) } return { setState, getState, render }
Utility Library
Stateless utility modules work naturally with caching — since they have no mutable state, sharing is free:
func repeat(s, n) { var result = "" for (var i = 0; i < n; i = i + 1) { result = result + s } return result } func capitalize(s) { return upper(slice(s, 0, 1)) + slice(s, 1) } return { repeat, capitalize }
How It Works
Under the hood, the module loader:
- Scans all source files for
importstatements and builds a dependency graph. - Detects cycles in the import chain and reports errors if found.
- Orders modules topologically — dependencies are placed before the files that use them.
- Wraps each module in a function. For cached modules, the function is called once and the result is stored in a variable. For
"use fresh"modules, the function is called at every import site. - Replaces each
import("path")in the source with a reference to the module’s variable (cached) or function call (fresh). - Combines everything into a single source that the compiler processes as one unit.