Arc compiles JavaScript to bytecode and executes it on the BEAM VM. This page explains the execution model, compilation pipeline, and how JavaScript values are represented in Erlang.
Execution model
Every JavaScript program in Arc runs as a BEAM process:
// This entire file executes in one BEAM process
const x = 10 ;
console . log ( x * 2 );
// Arc.spawn creates a NEW BEAM process
const pid = Arc . spawn (() => {
// This code runs in a different process
Arc . log ( 'Hello from child' );
});
Each process has its own heap, stack, and execution context. Processes communicate only through message passing — they share nothing.
Compilation pipeline
Arc transforms JavaScript source through multiple stages before execution:
Phase 1: Parsing
The parser (src/arc/parser.gleam) converts source text into an Abstract Syntax Tree:
Becomes:
Script ([
VariableDeclaration (
kind: Const ,
declarations: [
VariableDeclarator ( id: Identifier ( "x" ), init: Some ( Literal ( Number ( 10.0 ))))
]
),
ExpressionStatement (
BinaryExpression ( left: Identifier ( "x" ), operator: Multiply , right: Literal ( Number ( 2.0 )))
)
])
Phase 2: Compilation
The compiler (src/arc/compiler.gleam) converts AST to bytecode through three sub-phases:
Emit (AST → EmitterOp)
Generate symbolic operations with variable names: DeclareLocal ( "x" )
PushConst ( Number ( 10.0 ))
PutLocal ( "x" )
GetLocal ( "x" )
PushConst ( Number ( 2.0 ))
BinOp ( Multiply )
Scope (EmitterOp → IrOp)
Resolve variable names to local slot indices: // x is allocated to local slot 0
PushConst ( 0 ) // index into constant pool
PutLocal ( 0 ) // store to local[0]
GetLocal ( 0 ) // load from local[0]
PushConst ( 1 )
BinOp ( Multiply )
Resolve (IrOp → Op)
Replace label IDs with absolute addresses for jumps: // Labels become concrete program counter (PC) values
Jump ( 25 ) // not Jump(label_id: 3)
JumpIfFalse ( 42 ) // absolute bytecode address
The result is a FuncTemplate:
// From src/arc/vm/value.gleam
pub type FuncTemplate {
FuncTemplate (
ops: List ( Op ), // Bytecode operations
constants: List ( JsValue ), // Constant pool
local_count: Int , // Local variable slots needed
child_templates: List ( FuncTemplate ), // Nested functions
parameter_count: Int ,
is_strict: Bool ,
is_arrow: Bool ,
is_async: Bool ,
is_generator: Bool ,
)
}
Phase 3: Execution
The VM (src/arc/vm/vm.gleam) is a stack-based bytecode interpreter:
// Simplified VM loop (from src/arc/vm/vm.gleam)
fn run_loop ( state: State ) -> Result ( Completion , VmError ) {
case state.pc >= list. length (state.ops) {
True -> Ok ( NormalCompletion (state.accumulator, state.heap))
False -> {
let assert [op, .. ] = list. drop (state.ops, state.pc)
case execute_op (op, state) {
Ok (new_state) -> run_loop (new_state)
Error (err) -> Error (err)
}
}
}
}
The VM never sees source code or AST. All compilation happens ahead-of-time — even in the REPL.
Value representation
JavaScript values map to Erlang/Gleam types:
Primitives
Numbers
Objects
pub type JsValue {
JsUndefined
JsNull
JsBool ( Bool )
JsNumber ( JsNum ) // IEEE 754 float + NaN/Infinity
JsString ( String )
JsBigInt ( JsBigInt ) // Arbitrary precision integer
JsSymbol ( SymbolId ) // Unique symbol ID
JsObject ( Ref ) // Heap reference
JsUninitialized // TDZ for let/const
}
pub type JsNum {
Finite ( Float ) // Normal IEEE 754 value
NaN
Infinity
NegInfinity
}
Numbers are immediate values (not heap-allocated). pub type ObjectSlot {
ObjectSlot (
kind: ObjectKind , // Array, Function, Promise, etc.
properties: Dict ( String , Property ), // Named properties
elements: JsElements , // Array elements (dense)
prototype: Option ( Ref ), // [[Prototype]] link
symbol_properties: Dict ( SymbolId , Property ),
extensible: Bool ,
)
}
Objects are heap-allocated and accessed by Ref(id).
Heap management
The heap is an immutable dictionary arena:
// From src/arc/vm/heap.gleam
pub opaque type Heap {
Heap (
data: dict. Dict ( Int , HeapSlot ), // Slot storage
free: List ( Int ), // Free list for recycling IDs
next: Int , // Next fresh ID
roots: Set ( Int ), // GC roots (globals, builtins)
)
}
Allocating objects
Every object allocation returns a new heap:
let #(heap, obj_ref) = heap. alloc (heap, ObjectSlot (
kind: OrdinaryObject ,
properties: dict. from_list ([( "x" , data_property ( JsNumber ( Finite ( 42.0 ))))),
elements: js_elements. new (),
prototype: Some (object_proto),
symbol_properties: dict. new (),
extensible: True ,
))
The heap is immutable — alloc returns a new heap with the object added. The VM threads the heap through every operation.
Reading objects
case heap. read (heap, obj_ref) {
Some ( ObjectSlot ( properties: , .. )) -> {
// Access properties
case dict. get (properties, "x" ) {
Ok ( DataProperty ( value: val, .. )) -> val
_ -> JsUndefined
}
}
None -> JsUndefined // Dangling reference
}
References (Ref(id)) are just integers. They become “dangling” if the object is garbage collected or never allocated.
Stack machine operations
The VM maintains an operand stack for expression evaluation:
Compiles to:
GetLocal ( 0 ) // push x
PushConst ( 1 ) // push 2.0
BinOp ( Mul ) // pop 2 values, push result
PushConst ( 2 ) // push 3.0
BinOp ( Add ) // pop 2 values, push result
Stack state during execution:
Instruction Stack GetLocal(0)[10.0]PushConst(1)[10.0, 2.0]BinOp(Mul)[20.0]PushConst(2)[20.0, 3.0]BinOp(Add)[23.0]
The accumulator register holds the top-of-stack value. Many operations push to the stack or pop into the accumulator.
Function calls and closures
Function calls create new stack frames:
function add ( a , b ) {
return a + b ;
}
add ( 1 , 2 );
During execution:
Push callee
GetGlobal("add") → pushes the function object to the stack
Push arguments
PushConst(1) → push 1.0PushConst(2) → push 2.0
Call
Call(2) → pops 2 args + callee, creates new frame:SavedFrame (
pc: 15 , // Return address
ops: parent_ops,
locals: parent_locals,
stack: parent_stack,
// ...
)
Execute body
Function body runs with locals = [1.0, 2.0] (parameters bound to locals)
Return
Return → pops the frame, restores parent state, pushes return value to parent stack
Closures capture variables
Closures use boxed locals for captured variables:
function makeCounter () {
let count = 0 ;
return () => ++ count ;
}
count is boxed (wrapped in a mutable cell) so both the outer function and the closure can access it:
BoxLocal ( 0 ) // Box local[0] (count)
MakeClosure ( 0 , [ 0 ]) // Create closure with capture[0] = boxed local[0]
When the closure runs, it reads from the captured box:
GetBoxed ( 0 ) // Read from capture[0]
// increment logic
PutBoxed ( 0 ) // Write to capture[0]
Async and generators
Arc supports async/await and generators, but execution is synchronous on the BEAM:
Promises are data structures (not BEAM processes)
await is a VM operation that checks promise state
yield suspends execution and returns control to the caller
async function fetchData () {
const result = await fetch ( '/api/data' );
return result . json ();
}
Arc’s async/await is cooperative — promises don’t auto-resolve. You need message passing or external events to fulfill them.
REPL vs module execution
Two execution modes with different scoping:
Top-level declarations become global bindings: > const x = 10
> x * 2
20
Uses compile_repl() which emits DeclareGlobalLex/InitGlobalLex instead of DeclareLocal. Top-level declarations are module-scoped locals: // examples/simple.js
const x = 10 ; // local to module
export const y = x * 2 ;
Uses compile_module() which keeps top-level bindings as locals. Exports are extracted from the locals array after evaluation.
Next steps
Actor model Learn how Arc.spawn creates BEAM processes for concurrent execution
Modules Explore ES module compilation and the two-phase bundle lifecycle