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.
Resolution itself is host policy, not language semantics. The loader asks the embedder what a path names and where its bytes come from, and uses the answer verbatim. On the command line that is the filesystem; in an embedded host it can be anything. See Host-Side Resolution.
| 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 |
| Host callbacks | The embedder supplies resolution and reading, so the VM never touches a filesystem |
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.
import is not a runtime function. The loader rewrites every import("path") textually before compilation, so the path must be a string literal. A variable or a computed expression is not recognized. An import() written inside a function body still resolves when the program is loaded, not when the function runs.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
The import name from "path" statement itself emits no code. The loader erases it and substitutes the module wherever the symbol appears as name(). A bare name without parentheses is left alone, so the binding is only usable in call position.
Symbols are scoped to the file that declares them: two files may bind the same name to different modules. Binding the same symbol twice within one file is a load-time error.
Duplicate import symbol 'utils' in [main.zym]: First imported from: [math_utils.zym] Duplicate import from: [string_utils.zym]
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 cache is keyed on the resolved path, so two files that reach the same module through different relative paths still share one instance.
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.
The loader strips the directive before wrapping the module body, so it never reaches the compiler as a statement. Only imported modules are scanned for it. A "use fresh" at the top of the entry file has no effect.
| 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)
The chain is printed from the first module that takes part in the cycle, not from the entry file. The entry source is handed to the loader directly and never appears on the import stack.
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")
The joined path is then normalized: . segments are dropped, .. segments consume the one before them, backslashes fold to /, and empty segments collapse. The normalized string is the module’s identity: it is what the cache, the cycle detector and the host’s read callback all see.
| Importing file | Written | Resolves to |
|---|---|---|
main.zym | "lib/helpers.zym" | lib/helpers.zym |
lib/helpers.zym | "math/vectors.zym" | lib/math/vectors.zym |
lib/math/vectors.zym | "../helpers.zym" | lib/helpers.zym |
The last row lands on the same key as the first, so main.zym and vectors.zym share one instance of helpers.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 the entry source for
importstatements and walks each dependency depth-first, pulling every module in through the host’s read callback. - Detects cycles against the stack of in-flight imports and reports the chain if one closes.
- Orders modules in reverse discovery order, so a module is defined and initialized before the files that use it.
- 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 a call to its function (fresh). - Combines everything into a single source that the compiler processes as one unit: modules first, entry last.
Generated names derive from the resolved path. With debug names enabled the loader emits readable identifiers such as __module_lib_slash_helpers_dot_zym; otherwise it emits a short hash of the path. The readable form is what runtime error frames decode back into a file name.
Host-Side Resolution
Everything above is what a script writes. What a path means is decided by the host. The VM has no filesystem and no search path. When the loader reaches import("lib/helpers.zym") it asks the embedder two questions: what is the canonical name of this module, and what is its source text. The loader uses the answers as given. A host that answers from a ZIP archive, an in-memory table or a database serves modules from there. A host that refuses every key outside a permitted root is a sandbox. The script does not change, and neither does the VM.
The host side lives in one header:
#include "zym/module_loader.h"
Entry Points
ModuleLoadResult* loadModules(
ZymVM* vm,
const char* entry_source,
ZymSourceMap* entry_source_map,
const char* entry_path,
ModuleReadCallback read_callback,
void* user_data,
bool debug_names,
bool write_debug_output,
const char* debug_output_path
);
Walks the whole import graph reachable from entry_source and returns the combined compilation unit. The entry source is passed in directly. The loader never reads it through read_callback.
vm(ZymVM*) — VM whose allocator owns the result, and the VM the introspection accessors report on.entry_source(const char*) — already-preprocessed entry source.entry_source_map(ZymSourceMap*) — origin map for the entry, orNULL. Read, not owned: its segments are copied into the combined map.entry_path(const char*) — canonical name of the entry. Imports written in the entry resolve against it, and it is the cache key the entry occupies.read_callback(ModuleReadCallback) — called once per module with the resolved key.user_data(void*) — opaque pointer handed to both callbacks.debug_names(bool) — emit readable__module_<encoded path>identifiers instead of path hashes.write_debug_output(bool) — dump the combined source todebug_output_path.debug_output_path(const char*) — destination of the dump; ignored whenwrite_debug_outputis false.
Returns: an owned ModuleLoadResult*, never NULL. Check has_error before using it, and release it with freeModuleLoadResult either way.
ModuleLoadResult* loadModulesEx(
ZymVM* vm,
const char* entry_source,
ZymSourceMap* entry_source_map,
const char* entry_path,
ModuleReadCallback read_callback,
ModuleResolveCallback resolve_callback,
void* user_data,
bool debug_names,
bool write_debug_output,
const char* debug_output_path
);
loadModules plus an optional resolver. loadModules is a wrapper that calls this with resolve_callback = NULL, so passing NULL here behaves identically.
Reading Modules
typedef ModuleReadResult (*ModuleReadCallback)(const char* path, void* user_data);
Turns a resolved key into source text. Runs after cycle detection and after the cache check, so a module imported five times is read once.
path(const char*) — the canonical key: the loader’s normalized path, or exactly what the resolver returned. It is a key, not necessarily a filename.user_data(void*) — the pointer passed toloadModules.
The contract:
- Return a result whose
sourceisNULLto signal failure. The load stops and reportsFailed to read/preprocess module: [path]. sourcemust already be preprocessed: callzym_preprocessinside the callback. The loader does not preprocess for you.- Refusing a key is how a sandbox is enforced: a callback that returns
NULLfor anything outside its root cannot be talked out of it by the script.
typedef struct { char* source; ZymSourceMap* source_map; ZymFileId file_id; } ModuleReadResult;
source(char*) — preprocessed module text. The loader copies it, so the buffer stays yours to free.source_map(ZymSourceMap*) — per-module origin map produced byzym_preprocess, orNULL. The loader takes ownership and frees it when the load finishes.file_id(ZymFileId) — id fromzym_registerSourceFile, stamped onto every line this module contributes so error frames name the right file. UseZYM_FILE_ID_INVALIDif you have none.
Resolving Paths
typedef const char* (*ModuleResolveCallback)(const char* spec, const char* importer, void* user_data);
Decides what a path names. Runs before any path math, before the cycle detector and before the cache: no directory join and no normalization have happened yet. Whatever it returns is the module’s canonical key from that point on.
spec(const char*) — the raw string exactly as written at the call site:"./bar.zym","@/foo.zym","std/json". It is not pre-joined with the importer’s directory.importer(const char*) — canonical key of the module that wrote the import. For imports written in the entry module this is the entry’s own path, so entry-relative specs resolve against it. It is neverNULLon any reachable path.user_data(void*) — the pointer passed toloadModulesEx.
Returns: a NUL-terminated key, or NULL to fall back to the default relative resolution for that path. The pointer is borrowed. The loader copies it before returning, so it need not outlive the call.
The key drives four things: cycle detection, the cache slot, the path argument of read_callback, and the importer argument for everything that module imports in turn. The resolver runs once per import edge, including edges that end in a cache hit. It has to, since it decides which slot the lookup targets. It runs again for each import(...) call site when the loader rewrites it, and there is no active import frame during that pass: zym_currentImportDepth is 0 and the path accessors return NULL. A resolver must therefore be deterministic in spec and importer alone. The same pair must always produce the same key.
__module_ identifier, so it has to be encodable. Letters, digits and _ pass through; / \ . - : @ $ # % & * ~ ! and space have escapes; anything else passes through raw and yields an invalid identifier. See Supported Characters in Resolver-Returned Keys.Results
typedef struct { char* combined_source; ZymSourceMap* source_map; char** module_paths; int module_count; bool has_error; char* error_message; } ModuleLoadResult;
combined_source(char*) — every module plus the entry, in dependency order, as one buffer. Hand it tozym_compile.source_map(ZymSourceMap*) — one segment per line of that buffer, each pointing back at the file and line it came from. Pass it tozym_compileso diagnostics and stack frames name the original file rather than a line in the combined text.module_paths(char**) — canonical keys of everything loaded, entry first. A file-watching host can use it as its watch list.module_count(int) — number of entries inmodule_paths.has_error(bool) — true if the load failed; the source fields are then unset.error_message(char*) — the failure text: a cycle trace, a duplicate-symbol report, or a read failure.NULLon success.
void freeModuleLoadResult(ZymVM* vm, ModuleLoadResult* result);
Releases the result and everything it owns, including source_map and module_paths. Accepts NULL. Call it on the error path as well as the success path.
Import-Stack Introspection
A callback is handed one key and no context. The loader keeps the chain of in-flight imports on the VM so a callback, or any native reachable from one, can ask who triggered it without changing a signature.
int zym_currentImportDepth(ZymVM* vm); const char* zym_currentImportPathAt(ZymVM* vm, int i); const char* zym_currentImportCaller(ZymVM* vm);
| Call | Returns |
|---|---|
zym_currentImportDepth(vm) | Number of frames on the active import stack, counting the module being processed. 0 when no callback is in flight. |
zym_currentImportPathAt(vm, i) | Key at frame i. Frame 0 is the outermost import in flight; frame depth - 1 is the module being read. NULL out of range. |
zym_currentImportCaller(vm) | Key of the module that issued the current import, at frame depth - 2. NULL when the entry issued it. |
The entry module never occupies a frame: its source is passed to the loader directly and never goes through read_callback. So zym_currentImportCaller returning NULL means “the entry imported this”, not “no information”.
Inside a resolve callback the module being resolved has no frame yet. Depth still counts a slot for it, so zym_currentImportCaller names the importer exactly as it does in a read callback, but zym_currentImportPathAt(vm, depth - 1) is NULL.
0 and both path accessors return NULL; a binding that exposes these to script should turn that into an error rather than hand back stale data.A native reaches for these to answer what its path argument cannot: which permission set applies to this read, the importer’s rather than the entry’s; whether a bare path should be routed to a package directory or stay in the importer’s namespace; what chain to print when a load fails.
Example: Modules From Memory
A host with no filesystem at all. Modules live in a table, the read callback looks the key up and preprocesses it, and anything not in the table fails the load.
typedef struct { const char* key; const char* text; } VirtualModule; static const VirtualModule VFS[] = { { "lib/helpers.zym", "func twice(x) { return x * 2 }\nreturn { twice }" }, { "lib/config.zym", "return { debug: false }" }, }; static ModuleReadResult vfs_read(const char* path, void* user_data) { ModuleReadResult out = { NULL, NULL, ZYM_FILE_ID_INVALID }; ZymVM* vm = (ZymVM*)user_data; const char* text = NULL; for (size_t i = 0; i < sizeof(VFS) / sizeof(VFS[0]); i++) { if (strcmp(VFS[i].key, path) == 0) { text = VFS[i].text; break; } } if (!text) { // Unknown key: the load aborts and reports the path. return out; } ZymFileId fid = zym_registerSourceFile(vm, path, text, strlen(text)); ZymSourceMap* map = zym_newSourceMap(vm); const char* processed = NULL; if (zym_preprocess(vm, text, map, fid, &processed) != ZYM_STATUS_OK) { zym_freeSourceMap(vm, map); return out; } out.source = (char*)processed; // copied by the loader out.source_map = map; // owned by the loader from here out.file_id = fid; return out; }
ModuleLoadResult* modules = loadModules(
vm, preprocessed_entry, entry_map, "main.zym",
vfs_read, vm,
true, false, NULL
);
if (modules->has_error) {
fprintf(stderr, "%s\n", modules->error_message);
freeModuleLoadResult(vm, modules);
return 1;
}
zym_compile(vm, modules->combined_source, chunk,
modules->source_map, "main.zym", config, NULL);
The script on top of this is unchanged: it still writes import("lib/helpers.zym"), still gets a cached singleton, still trips the same cycle detector. Only the host knows there is no lib directory.
For a resolver that maps paths into namespaces, the surrounding compile pipeline, and the full escape table for generated identifiers, see the Embedding Guide.