JavaScript VM & Natives
The working surface of zym-js: creating a VM, running code, exposing JS functions to scripts, and moving values across the boundary.
Zym singleton or the createZym factory, are covered in
Overview. Everything below assumes you already have a vm.
The VM Object
A VM is a self-contained interpreter with its own heap, globals, and GC. JS drives it: you compile and run
scripts on it, and scripts call back into JS through the natives you register on it. Every method throws
ZymError on compile or runtime failure, and the vm object itself is opaque.
Treat it as a handle.
const vm = await Zym.newVM(); vm.registerNative("print(a)", (a) => { console.log(a.toJS()); return null; }); vm.run(`print("hi");`); vm.free();
Execution is synchronous. vm.run, vm.call, and native callbacks all block, and
nothing else in JS gets a turn until the script finishes or a preemption entry fires. That is the one
carve-out, and it is the only moment JS code runs while a script executes: an entry with a handler runs the
handler and execution continues, an entry without one leaves the VM suspended and throws
ZymSuspended out of run(). Both are covered in
Bounds & Control.
| Member | What it does |
|---|---|
vm.run(source, options?) | Compile and execute a source string in one call |
vm.compile(source, options?) | Compile once into a chunk you can run many times |
vm.serialize(chunk) / vm.loadBytecode(bytes) | Turn a chunk into bytes, and bytes back into a chunk |
vm.defineGlobal(name, value) | Expose a JS value as a Zym global |
vm.call(funcName, ...args) | Invoke a Zym function from JS |
vm.callValue(callable, args?) | Invoke a callable value held by a wrapper or raw handle |
vm.hasFunc(name, arity?) / vm.getFunc(name) | Find out what a script defines before you call it |
vm.registerNative(signature, fn) | Expose a JS function to script |
vm.on("error", listener) | Subscribe to compile and runtime errors as a stream |
vm.free() | Release the VM and everything it owns |
requestStop(), info(), and resume(). These resource bounds live on the same
vm and are documented in Bounds & Control.
Running Code
There are two ways to get a script into the VM, and they differ only in whether you keep the compiled form.
vm.run compiles and executes in one call, which is what you want for source you see once.
vm.compile hands the compiled form back as a chunk, which is what you want for source you
execute repeatedly. A chunk is also the thing that can be written out as bytes.
Compile & Run in One Call
vm.run(source, options?) compiles and executes a source string in one call. Both options are
about diagnostics: file is the name errors are reported against, and
includeLineInfo controls whether line debug information is kept in the compiled chunk.
vm.run(`print("hello");`); vm.run(source, { file: "user-script.zym", includeLineInfo: true });
options.file— filename shown in errors (default"<script>").options.includeLineInfo— keep line debug info in the compiled chunk (defaulttrue).
Compile Once, Run Many Times
vm.compile(source, options?) hands back a chunk instead of executing it, and each
chunk.run() runs that chunk again: compile once, run many times. Freeing the chunk when
you are done with it is optional; GC releases it otherwise.
const chunk = vm.compile(`print("hi");`, { file: "preamble.zym" }); chunk.run(); chunk.run(); chunk.free(); // optional; GC releases it otherwise
Saving & Loading Bytecode
A chunk can also leave the process. vm.serialize(chunk) returns a Uint8Array, and
vm.loadBytecode(bytes) turns those bytes back into a chunk you can run, accepting either a
Uint8Array or an ArrayBuffer. Where the bytes sit in between is up to you: disk,
localStorage, IndexedDB, or the response to a fetch.
const bytes = vm.serialize(chunk); // Uint8Array // ...ship bytes to disk, localStorage, IndexedDB, fetch... const reloaded = vm.loadBytecode(bytes); // accepts Uint8Array or ArrayBuffer reloaded.run();
Bytecode is Zym's own binary format and carries a magic header. A given zym-js build loads the bytecode that build produced; do not rely on bytecode portability across versions until 1.0.
Defining Globals
vm.defineGlobal(name, value) exposes a JS value as a Zym global. The value is converted by the
rules in Marshaling JS to Zym, and a global defined this way is reassignable from
script.
vm.defineGlobal("PI", Math.PI); vm.defineGlobal("USER", "ada"); vm.defineGlobal("CONFIG", { debug: true, limits: [1, 2, 3] });
Functions are not marshalable, so a callback cannot be handed across this way. Register it as a native and expose it by name instead. See Registering Native Functions.
Calling Into a Script
The other direction: JS reaching into a script that has already run. Running the source is what defines its
functions, so the shape is to run once and then call the entry points the script left behind,
by name or through a callable value it handed you.
Calling a Function by Name
vm.call(funcName, ...args) invokes a Zym function from JS. Arguments are auto-marshaled; the
return value is decoded with toJS()-style rules.
vm.run(`func add(a, b) { return a + b; }`); const sum = vm.call("add", 2, 3); // 5
run() is executing; when one fires during a vm.call, the VM is terminated rather
than suspended, and there is nothing to resume. See Bounds & Control.
Calling a Callable Value
Not every callable has a name to reach it by. A script can hand back a closure, and toJS()
decodes that into a JS function you invoke directly.
vm.callValue(callable, args?) is the substrate underneath: it invokes an arbitrary callable
value, a Zym function or closure, held by a ZymValue wrapper or a raw handle. The
decoded callable uses it for you, so you rarely need to call it directly.
vm.run(`func makeAdder(n) { return func(x) { return x + n; }; }`); const add10 = vm.call("makeAdder", 10); // decoded as a JS callable add10(5); // 15 (uses callValue under the hood)
Discovering What a Script Defines
When the script is not yours, find out whether it defines something before you call it. There are two probes, and they answer different questions. The difference matters most for variadic entry points.
hasFunc with one argument is true if any callable by that name exists, at any arity.
With two, it asks whether a call with exactly that many arguments would dispatch, which includes a variadic
whose fixed prefix is short enough.
vm.run(`func main(...args) { return length(args) }`); vm.hasFunc("main"); // true vm.hasFunc("main", 3); // true -- variadic accepts 3 vm.hasFunction("main", 3); // false -- strict exact-slot probe
That last line is why hasFunc exists: hasFunction matches a fixed arity slot
exactly, so it misses a variadic entry point. Use hasFunc for discovery,
hasFunction when you mean a precise signature.
getFunc returns a reusable callable, or null if there is no such function. The
name is resolved once and the result is identity-stable, so it works as a Map key.
const main = vm.getFunc("main"); if (main) main(1, 2, 3); vm.getFunc("main") === main; // true
Registering Native Functions
All natives are registered through a single method. Whether the function takes a fixed number of arguments or is variadic is controlled by the signature string, not by the API.
Signature grammar
funcName(param1, param2, ..., paramN) // fixed arity funcName(param1, ...) // variadic (any number of extra args) funcName(param1, ...rest) // variadic, rest param named funcName(...) // fully variadic funcName() // zero-arg
- Parameter names are documentation only. Zym's parser records the count and the variadic flag.
..., optionally followed by a name, marks the native as variadic. It must be the last item.- Fixed arity is 0 to 10 positional parameters. Variadic natives take 0 to 10 positional parameters followed by any number of trailing args.
Exact-arity natives
vm.registerNative("greet(name)", (name) => { return `hello, ${name.toJS()}`; }); vm.registerNative("add(a, b)", (a, b) => { return a.toJS() + b.toJS(); }); vm.registerNative("now()", () => Date.now());
Arguments arrive as ZymValue wrappers. Return anything marshalable: primitives, arrays,
plain objects, or another ZymValue. Returning undefined is treated as
null.
Variadic natives
vm.registerNative("log(level, ...parts)", (level, ...parts) => { console.log(`[${level.toJS()}]`, ...parts.map((p) => p.toJS())); return null; }); vm.registerNative("sum(...xs)", (...xs) => { return xs.reduce((acc, v) => acc + v.toJS(), 0); });
The rest parameters appear as a JS spread of ZymValue wrappers, exactly like a normal JS
variadic function.
Closures (capturing JS state)
Natives are just JS functions, so they close over variables naturally. There is no special API.
function makeCounter() { let n = 0; return () => { n += 1; return n; }; } vm.registerNative("tick()", makeCounter()); vm.run(`print(tick()); print(tick()); print(tick());`); // 1 2 3
Any JS callable works: arrow functions, bound methods, class instance methods.
Errors inside a native
Throwing from a native surfaces to the script as a Zym runtime error, which also propagates out as a
ZymError from vm.run / vm.call.
vm.registerNative("parseJSON(src)", (src) => { try { return JSON.parse(src.toJS()); } catch (e) { throw new Error(`bad json: ${e.message}`); } });
run() is executing, so anything you call back into the VM from inside a
native is terminated rather than suspended when a bound fires. That matters for a native that runs long or
re-enters the VM. See Bounds & Control.
Values Across the Boundary
Every value arriving from Zym is wrapped, whether it is a native argument or a script return value. A
ZymValue is a handle onto a value that still lives in the VM, not a copy of it. The wrapper
exposes:
| Member | Description |
|---|---|
kind | Numeric kind code; compare against the KIND export |
isNull() / isBool() / isNumber() / isString() / isList() / isMap() / isCallable() | Convenience type-checks |
toJS() | Decode into a natural JS value (see Decoding With toJS()) |
display() | VM-formatted string (same output as a Zym print) |
toString() | Alias of display(); safe to use in template literals |
dispose() | Release the underlying handle eagerly (optional) |
You can also pass a ZymValue back to any API that accepts marshaled input
(defineGlobal, call, a native's return value); it is forwarded without copying.
Decoding With toJS()
toJS() produces the most natural JS shape for each Zym kind:
| Zym kind | JS result |
|---|---|
null | null |
bool | boolean |
number | number |
string | string |
list | Array (elements decoded recursively) |
map | plain Object (values decoded recursively) |
struct | plain Object with a non-enumerable __type string (declared type name) |
enum variant | frozen { __enum, name, ordinal } |
function / closure | a callable JS function; invoking it calls back into the VM. Exposes .free() / [Symbol.dispose] for deterministic cleanup (otherwise GC releases it). |
continuation / anything else | the ZymValue wrapper unchanged |
Cycles in maps and structs are preserved via shared references, so decoding never recurses infinitely.
Callables returned from toJS() own their own handle, so they stay valid after the original
ZymValue or result handle is gone. They are invoked with plain JS args, which are
auto-marshaled, and the return value is decoded with the same rules as the rest of the table.
vm.run(`func make() { return (x) => x * x; }`); const sq = vm.call("make"); // sq is a JS function sq(7); // 49 sq.free(); // optional; GC also handles it
display() vs toJS()
toJS()means "give me the JS form I can program against." Use this inprintnatives, logging, assertions, anywhere you would normally touch a JS value.display()means "give me the VM's canonical text form," identical to what a Zym script would print. Useful for format-awareprintimplementations that want to match core parity.
Both are safe on every kind. Prefer toJS() unless you specifically need VM formatting.
JSON.stringify(someZymValue) hangs or
crashes: the wrapper carries a back-reference to the wasm Module, whose HEAP TypedArrays are
huge. Call .toJS() first, then stringify the decoded value. String(wrapper) and
template literals are fine because they route through display().
Marshaling JS to Zym
The other direction. When you hand a JS value to defineGlobal, vm.call, or a
native's return value, the bridge converts:
| JS value | Zym value |
|---|---|
null / undefined | null |
boolean | bool |
number / bigint (that fits in double) | number |
string | string |
Array | list (elements marshaled recursively) |
plain Object | map (values marshaled recursively) |
ZymValue | passed through unchanged |
| other (functions, class instances, etc.) | rejected; register functions as natives instead |
The last row is a rejection, not a coercion: there is no silent conversion of a JS function into a Zym
value. The first row is why a native that falls off the end returns null to the script rather
than something undefined.
Error Handling
All VM operations that can fail throw ZymError.
import Zym, { ZymError } from "@zym-lang/zym-js"; const vm = await Zym.newVM(); try { vm.run("var x = ;"); // syntax error } catch (e) { if (e instanceof ZymError) { console.error(e.status); // e.g. STATUS.COMPILE_ERROR console.error(e.details); // [{ status, file, line, message }, ...] } else { throw e; } }
e.status— status code; compare against theSTATUSnamed export.e.details— one entry per diagnostic emitted by the VM. Compile failures often yield multiple.
Errors as a stream
vm.on("error", listener) subscribes to compile and runtime errors as they are emitted. It
fires in addition to the thrown ZymError, which is what makes it useful for logging every
diagnostic from a single compile rather than only the one that surfaced.
const off = vm.on("error", (e) => { console.warn(`[${e.file}:${e.line}] ${e.message}`); }); // ... off(); // unsubscribe
ZymSuspended, deliberately not a ZymError: one means you stopped it, the other
means it failed on its own, and you almost always want to report those differently. See
Bounds & Control.
Memory & Lifetimes
You should not need to think about memory at all in typical use. Here is what the bridge does for you:
ZymValuewrappers are tied to aFinalizationRegistry; when JS collects the wrapper, the underlying Zym handle is released.- Native arguments are scoped to the call. They are released automatically when the native returns, unless you kept a JS reference alive, in which case they survive for the wrapper's lifetime.
- JS callbacks you register as natives live in an id-keyed registry. The Zym GC decides when a native closure is unreachable and notifies the bridge to drop the entry.
- Globals and script functions are rooted by the VM itself.
There is no manual rooting to do. The bridge anchors every live handle in a hidden VM map and releases it
when the JS wrapper is collected, so you never touch pushRoot / popRoot.
Freeing the VM
The one place determinism matters is teardown: call vm.free() when you are done, because
finalizers may not fire before process exit. It releases the VM and everything it owns and is safe to call
more than once. If you forget, the bridge's own teardown walks and releases every outstanding handle, and a
FinalizationRegistry will eventually clean up a forgotten VM, but the JS GC makes no
timing promises.
If you hold on to a ZymValue past vm.free(), using it (toJS,
display, kind, and so on) throws a ZymError reading
"ZymValue used after its VM was freed" instead of reading freed wasm memory.
For advanced users who care about peak memory, ZymValue.dispose() releases a handle eagerly.
See also: Overview — Bounds & Control — C Embedding Guide