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.

Before this page: installing the package and picking an entry point, either the lazy 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.

JavaScript
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.

MemberWhat 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
Also on this object: preemption entries, the memory ceiling, 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.

JavaScript
vm.run(`print("hello");`);
vm.run(source, { file: "user-script.zym", includeLineInfo: true });

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.

JavaScript
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.

JavaScript
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.

JavaScript
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.

JavaScript
vm.run(`func add(a, b) { return a + b; }`);
const sum = vm.call("add", 2, 3);   // 5
Bounds behave differently here. A resource bound can only pause work that 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.

JavaScript
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.

JavaScript
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.

JavaScript
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

Signature
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

Exact-arity natives

JavaScript
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

JavaScript
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.

JavaScript
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.

JavaScript
vm.registerNative("parseJSON(src)", (src) => {
    try   { return JSON.parse(src.toJS()); }
    catch (e) { throw new Error(`bad json: ${e.message}`); }
});
A native is host code on the script's stack. Resource bounds count VM instructions and can only pause work that 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:

MemberDescription
kindNumeric 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 kindJS result
nullnull
boolboolean
numbernumber
stringstring
listArray (elements decoded recursively)
mapplain Object (values decoded recursively)
structplain Object with a non-enumerable __type string (declared type name)
enum variantfrozen { __enum, name, ordinal }
function / closurea callable JS function; invoking it calls back into the VM. Exposes .free() / [Symbol.dispose] for deterministic cleanup (otherwise GC releases it).
continuation / anything elsethe 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.

JavaScript
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()

Both are safe on every kind. Prefer toJS() unless you specifically need VM formatting.

Do not stringify a wrapper directly. 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 valueZym value
null / undefinednull
booleanbool
number / bigint (that fits in double)number
stringstring
Arraylist (elements marshaled recursively)
plain Objectmap (values marshaled recursively)
ZymValuepassed 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.

JavaScript
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;
    }
}

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.

JavaScript
const off = vm.on("error", (e) => {
    console.warn(`[${e.file}:${e.line}] ${e.message}`);
});
// ...
off();    // unsubscribe
A stopped run is not a failed one. When a resource bound suspends the VM, the throw is 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:

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: OverviewBounds & ControlC Embedding Guide