Memory management
Memory mode is chosen once per compiled binary via the -mm=manual|gc|auto flag, defaulting to manual. The big divergence from a managed runtime: manual mode never frees anything on its own, so a program's footprint grows for its whole run unless it calls Memory.free(x) by hand — fine for short-lived CLI tools, a real concern for long-running servers. gc swaps in the Boehm collector for a one-flag fix; auto has the compiler itself insert frees at points its escape analysis proves safe — no runtime collector, and values it can't prove simply keep leaking.
manual mode (-mm=manual, default)
Works, with caveatsThe default mode: every heap allocation the compiler emits (arrays, object literals, closure environments, string concatenation/slicing/templates, Map/Set backing tables, JSON/Date scratch buffers, boxed any payloads) goes through a plain malloc/realloc and is only released by an explicit Memory.free(x). That call resolves the value's underlying heap pointer — array data pointer, object struct, closure header plus environment, Map/Set backing buffers — and frees it shallowly, never anything reachable through it.
Returnsvoid — Memory.free(x) releases x's own top-level allocation(s).
let big: string = "hello " + "world" // concatenation result -> malloc'd
console.log(big.length) // 11
Memory.free(big)
console.log(big === null) // true — the variable's own storage was nulled- Never frees on its own — a program's footprint grows monotonically with runtime unless the programmer calls
Memory.free(x)by hand - Same footguns as C, including that a string *literal* is a compile-time global constant, not
malloc'd, so freeing one crashes exactly like C'sfree("literal")
gc mode (-mm=gc)
Works, with caveatsOpt-in Boehm-Demers-Weiser collector (libgc): it swaps the declared @malloc/@realloc for @GC_malloc/@GC_realloc and links -lgc, with no per-feature codegen change beyond the allocation call sites, since the collector conservatively scans the native stack, registers, and managed heap for live pointers. Program output is identical to manual mode — gc only changes when memory is reclaimed. Memory.free(x) stays legal and lowers to GC_free, an always-safe early-release hint.
Returnsvoid — Same Memory.free(x) surface; here it hints the collector rather than calling libc free().
let s: string = "abcdefghijklmnopqrstuvwxyz0123456789" + "abcdefghijklmnopqrstuvwxyz0123456789";
total = total + s.length;- Requires
bdw-gc/libgcinstalled at build time (brew install bdw-gc/apt-get install libgc-dev/apk add gc-dev) — this compiler's only non-libc external dependency, and only for binaries compiled with-mm=gc - Strictly safer for long-running processes than
manual, but the default hasn't been flipped to it - Collection is conservative and optimizer-dependent: on x86-64 -O2 builds a referent created and orphaned in straight-line code can stay pinned by a stale stack-slot copy, so its WeakRef/FinalizationRegistry death signal may never fire; referents churning through reused code paths collect normally
auto mode (-mm=auto)
Works, with caveatsCompiler-inserted frees at compile-time-proven-safe points, with no runtime collector. /** @free */ frees a binding at every exit of its declaring block; /** @owned */ frees right after the statically-determined last use (on locals, and on parameters via a function-level '@owned name' tag — the callee frees its argument, and every call site must pass a value the caller provably no longer uses). Every unannotated local that passes the same conservative escape check is freed at block exit automatically. The annotations are honored in all three modes — they are compiler-checked, compiler-placed Memory.free — and an annotation the analysis cannot prove safe is a compile error, never a silent no-op. Memory.free(x) itself is a compile error in this mode: the compiler owns every free.
- The escape analysis is deliberately very conservative — a value that is returned, stored, aliased, spread, passed to a non-whitelisted call, captured by a closure, or reassigned (outside the owning-fresh rebind shapes) is never freed (it leaks, as in manual mode)
- Frees are shallow (the binding's own top-level allocation), except transitively-owned typed
JSON.parse/res.json()trees, which are freed deep — row structs, strings, and nested array buffers reclaimed at block exit; extracting an interior pointer into a binding, mutating, iterating, or calling a method on the tree downgrades it back to the shallow free - Unsupported in async/generator functions (values may live across suspension points); thrown paths leak rather than free
-compat=jsdynamic objects and class instances are never freed;@ownedparameters work on directly-called function declarations onlyMemory.free(x)is a compile error in this mode (the compiler's own inserted frees can't account for a manual free it didn't plan for)
-optimize-memory
Works, with caveatsAllocation optimizations with no semantic change. Stack allocation: object literals, (non-async) closure headers/environments, tuple-annotated literals, and this-clean class instances the escape analysis proves never outlive their block become entry-block allocas instead of heap allocations — less allocator pressure and better cache locality, measured as exactly the object allocations disappearing from peak RSS on allocation-churn loops clang cannot elide itself. By-value tuple returns: an eligible small-tuple-returning function returns a register-passable struct aggregate, so return [a, b] and the Go-style const [v, err] = f() consumer are allocation-free end to end. Composes with every -mm mode; a stack value is never auto-freed and a registered FinalizationRegistry target stays heap so its free death-signal keeps firing.
- Stack allocation covers non-escaping object/closure/tuple literals and class instances bound to let/const — Map/Set, plain arrays' data buffers, and async closures stay heap; captured variables' shared cells stay heap regardless
- A class instance stack-allocates only when the whole class passes a conservative this-audit: any method that returns/stores/passes this, captures it in a nested closure, or carries decorators keeps every instance heap-bound (return-this chaining included)
- By-value tuple returns require ≤2 plain scalar fields on a non-async top-level function never used as a value — larger tuples, aggregate-slot fields (array/nullable/any), async functions, generics, and function values keep the pointer ABI; a non-destructuring consumer respills to the heap at the same allocation count as before
- The escape analysis is the same deliberately-conservative pass -mm=auto uses: anything not provably block-local stays heap
- Explicit @free/@owned declarations keep their heap+free meaning; off by default while the analysis matures
/** @value */ flat arrays
Works, with caveatsOpts one array binding into a flat value-type layout: elements are packed inline in a single contiguous buffer (array of structs, stride = the element's struct size) instead of the default array-of-pointers — one allocation, cache-friendly traversal, vectorizable loops. This is a per-binding annotation, not a compiler flag, because it deliberately changes aliasing semantics: writing a value into a slot copies its fields (later mutation of the source object is invisible through the array), while reading arr[i] yields an interior-pointer view, so arr[i].x = 1, a taken const v = arr[i], and for...of bindings all mutate the array in place.
- V1 surface is deliberately narrow: array-literal construction (no spread elements), index read/write, .length, for...of, and .push — everything else (map/filter/sort/slice, spread, destructuring, other mutators) is a compile-time rejection naming the supported set
- Element type must be a fixed-shape plain object (interface/object-literal shape); classes, tuples, nested arrays, and dynamic values are rejected
- Function-local bindings only — a @value array read by other functions is rejected
- .push may realloc-move the buffer, invalidating previously-taken element views (the Vec/C++-vector contract) — re-index after a push
- Mutually exclusive with @free/@owned; under -mm=auto the buffer is freed at block exit through the shared array free path