Arc supports ES modules with import and export syntax. Modules are compiled ahead-of-time into a self-contained bundle and evaluated in dependency order.
Module lifecycle
Arc uses a two-phase module system:
Phase 1: Compile bundle
Parse and compile the entry module and all its dependencies into a ModuleBundle. No code is executed yet.
Phase 2: Evaluate bundle
Execute modules in dependency order (depth-first post-order), link imports/exports, and run module bodies.
This design enables:
No runtime parsing — the VM never sees source code
Serializable bundles — compile once, load many times
Ahead-of-time analysis — detect missing modules before execution
The module system is implemented in src/arc/module.gleam. Study compile_bundle() and evaluate_bundle() for details.
Compiling modules
compile_bundle()
The compile_bundle() function takes an entry point and compiles all dependencies:
// From src/arc/module.gleam
pub fn compile_bundle (
entry_specifier: String ,
entry_source: String ,
resolve_and_load: fn ( String , String ) -> Result (#( String , String ), String ),
) -> Result ( ModuleBundle , ModuleError )
Parameters:
entry_specifier — Entry module path (e.g. "examples/counter_actor.js")
entry_source — Source code of the entry module
resolve_and_load — Callback to load dependencies: fn(raw_specifier, parent_specifier) -> Result(#(resolved_path, source), error)
Returns:
A ModuleBundle containing all compiled modules:
pub type ModuleBundle {
ModuleBundle (
entry: String , // Entry module specifier
modules: Dict ( String , CompiledModule ) // All modules in the graph
)
}
pub type CompiledModule {
CompiledModule (
specifier: String ,
template: FuncTemplate , // Compiled bytecode
import_bindings: List (#( String , List ( ImportBinding ))),
export_entries: List ( ExportEntry ),
scope_dict: Dict ( String , Int ), // Variable name → local index
specifier_map: Dict ( String , String ), // Raw specifier → resolved path
requested_modules: List ( String ), // Dependencies
)
}
Example: Compiling from disk
// From src/arc.gleam
fn run_module_file ( path: String , source: String ) -> Nil {
let h = heap. new ()
let #(h, b) = builtins. init (h)
let #(h, global_object) = builtins. globals (b, h)
case module. compile_bundle (path, source, resolve_and_load_dep) {
Error (err) -> print_module_error (h, err)
Ok (bundle) ->
case module. evaluate_bundle (bundle, h, b, global_object) {
Ok ( _ ) -> Nil
Error (err) -> print_module_error (h, err)
}
}
}
fn resolve_and_load_dep (
raw_specifier: String ,
parent_specifier: String ,
) -> Result (#( String , String ), String ) {
let resolved = resolve_specifier (raw_specifier, parent_specifier)
case read_file (resolved) {
Ok (source) -> Ok (#(resolved, source))
Error (err) -> Error ( "file not found: " <> resolved)
}
}
The resolve_and_load callback is how you integrate custom module resolution (Node.js-style node_modules, URL imports, etc.).
Evaluating modules
evaluate_bundle()
Once you have a ModuleBundle, evaluate it:
pub fn evaluate_bundle (
bundle: ModuleBundle ,
heap: Heap ,
builtins: Builtins ,
global_object: Ref ,
) -> Result (#( JsValue , Heap ), ModuleError )
Execution order:
Depth-first post-order traversal — dependencies execute before dependents
Circular dependencies are allowed — the cycle is detected and execution continues
Imports are resolved — exported values from evaluated dependencies are bound as lexical globals
Module body runs — the compiled bytecode executes
Exports are collected — the module’s local variables are mapped to export names
Each module executes exactly once. Re-evaluating the same bundle reuses cached results.
Import syntax
Arc supports standard ES module import syntax:
Named imports
Default imports
Namespace imports
import { foo , bar } from './utils.js' ;
import { original as renamed } from './utils.js' ;
Compiles to NamedImport bindings: [
NamedImport ( imported: "foo" , local: "foo" ),
NamedImport ( imported: "bar" , local: "bar" ),
NamedImport ( imported: "original" , local: "renamed" ),
]
import React from 'react' ;
Compiles to DefaultImport: DefaultImport ( local: "React" )
Default imports resolve to the "default" export. import * as utils from './utils.js' ;
Compiles to NamespaceImport: NamespaceImport ( local: "utils" )
Creates a namespace object with all exports as properties.
Import resolution
At evaluation time, imports are resolved by looking up the dependency’s exports:
// From src/arc/module.gleam
fn resolve_imports (
evaluated: Dict ( String , Dict ( String , JsValue )),
specifier_map: Dict ( String , String ),
import_bindings: List (#( String , List ( ImportBinding ))),
heap: Heap ,
_global_object : Ref ,
) -> #( Heap , Dict ( String , JsValue ))
This function:
Looks up the dependency in evaluated (already-executed modules)
For each import binding, extracts the corresponding export value
Returns a dict of name → value to be used as lexical globals
Imported bindings are not properties of globalThis. They’re lexical bindings in the module scope, compiled as local variables.
Export syntax
Arc supports several export patterns:
Named exports
Default export
Re-exports
export const x = 10 ;
export function foo () { return 42 ; }
const y = 20 ;
export { y };
export { y as renamed };
Compiles to LocalExport entries: [
LocalExport ( export_name: "x" , local_name: "x" ),
LocalExport ( export_name: "foo" , local_name: "foo" ),
LocalExport ( export_name: "y" , local_name: "y" ),
LocalExport ( export_name: "renamed" , local_name: "y" ),
]
export default function () { return 42 ; }
// or
const value = 42 ;
export default value ;
Compiles to a LocalExport with export name "default": LocalExport ( export_name: "default" , local_name: "_default_0" )
export { foo } from './other.js' ;
export { foo as bar } from './other.js' ;
export * from './other.js' ;
export * as ns from './other.js' ;
Compiles to re-export entries: [
ReExport ( export_name: "foo" , imported_name: "foo" , source_specifier: "./other.js" ),
ReExport ( export_name: "bar" , imported_name: "foo" , source_specifier: "./other.js" ),
ReExportAll ( source_specifier: "./other.js" ),
ReExportNamespace ( export_name: "ns" , source_specifier: "./other.js" ),
]
Export collection
After a module executes, exports are extracted from its local variables:
// From src/arc/module.gleam
fn collect_exports (
evaluated: Dict ( String , Dict ( String , JsValue )),
specifier_map: Dict ( String , String ),
export_entries: List ( ExportEntry ),
scope_dict: Dict ( String , Int ),
locals: array. Array ( JsValue ),
heap: Heap ,
) -> #( Dict ( String , JsValue ), Heap )
For each export entry:
LocalExport : Look up the local variable by name in scope_dict, read from locals array
ReExport : Look up the dependency’s exports in evaluated, extract the named export
ReExportAll : Copy all exports (except "default") from the dependency
ReExportNamespace : Create a namespace object with all dependency exports
Exports are values at evaluation time — if a module exports a mutable binding, the importer sees the current value , not a live binding.
Builtin modules
Arc has one builtin module: "arc".
import { spawn , send , receive , self } from 'arc' ;
const pid = spawn (() => {
const msg = receive ();
log ( 'Received:' , msg );
});
send ( pid , 'Hello!' );
The "arc" module is not in the bundle. It’s resolved at evaluation time from the builtins:
// From src/arc/module.gleam
fn extract_builtin_exports ( h: Heap , b: Builtins ) -> Dict ( String , JsValue ) {
case heap. read (h, b.arc) {
Some ( ObjectSlot ( properties: props, .. )) ->
dict. fold (props, dict. new (), fn (acc, name, prop) {
case prop {
DataProperty ( value: v, .. ) -> dict. insert (acc, name, v)
_ -> acc
}
})
_ -> dict. new ()
}
}
This extracts all properties from the Arc global object and treats them as exports.
Module bundling
The ModuleBundle is a pure Erlang term — you can serialize it:
pub fn serialize_bundle ( bundle: ModuleBundle ) -> BitArray {
erlang. term_to_binary (bundle)
}
pub fn deserialize_bundle ( data: BitArray ) -> ModuleBundle {
erlang. binary_to_term (data)
}
Use case: Compile modules once, serialize to disk, and load the precompiled bundle later:
// Compile once
let assert Ok (bundle) = module. compile_bundle (entry, source, resolve_and_load)
let binary = module. serialize_bundle (bundle)
file. write_bytes (binary, to: "app.bundle" )
// Load later (no parsing!)
let assert Ok (binary) = file. read_bytes ( "app.bundle" )
let bundle = module. deserialize_bundle (binary)
let assert Ok ( _ ) = module. evaluate_bundle (bundle, h, b, global_object)
Precompiled bundles are portable across BEAM nodes — you can compile on one machine and execute on another.
Circular dependencies
Arc handles circular dependencies correctly:
// a.js
import { b } from './b.js' ;
export const a = 'A' ;
console . log ( 'a.js: b =' , b );
// b.js
import { a } from './a.js' ;
export const b = 'B' ;
console . log ( 'b.js: a =' , a );
Evaluation order:
Start evaluating a.js
a.js imports b.js → pause and evaluate b.js
b.js imports a.js → cycle detected , skip re-evaluation
b.js runs with a = undefined (not yet initialized)
b.js exports b = 'B'
Resume a.js with b = 'B'
a.js exports a = 'A'
Output:
b.js: a = undefined
a.js: b = B
Imported bindings in circular dependencies may be undefined if the exporting module hasn’t run yet. Design your module graph to avoid tight cycles.
Error handling
Module errors are categorized:
pub type ModuleError {
ParseError ( String ) // Syntax error in source
CompileError ( String ) // Unsupported feature or compiler bug
ResolutionError ( String ) // Dependency not found
LinkError ( String ) // Import binding failed
EvaluationError ( JsValue ) // Exception thrown during execution
}
If a module throws during evaluation, the error is cached:
// broken.js
throw new Error ( 'broken' );
// main.js
import './broken.js' ; // EvaluationError
The exception is caught and wrapped in EvaluationError(JsValue) where JsValue is the thrown error object.
Next steps
Actor model Learn how to use Arc.spawn for concurrent module execution
JavaScript on BEAM Understand how modules are compiled and executed on the BEAM