Memory without a garbage collector
JavaScript runtimes reclaim memory with a garbage collector: objects live until the collector finds them unreachable, which costs memory headroom and pauses. Velt has no garbage collector. Memory is freed at a known point, when its last reference goes away, so there are no collection pauses, no heap to tune, and cleanup code runs exactly when you expect.
You don't manage memory by hand either. Memory management is automatic: you write no free
and no annotations, the compiler works out when each object can be freed, and the code you write
behaves as it does in JavaScript.
Values: numbers and strings
Numbers, booleans and strings behave exactly as in JavaScript: assigning one copies it, and the copy is independent.
let a = "tea";
let b = a; // a copy
b += " with milk";
console.log(a, b); // tea tea with milkStrings are immutable values. Short ones (up to 23 bytes) live inline with no allocation; longer ones share one reference-counted buffer, so a copy is cheap and never duplicates the text.
Objects are references
Arrays, maps, class instances, object literals and closures are references, as in
JavaScript: assigning an object to a second variable, storing it in another object or returning
it refers to the same object. .clone() makes an independent deep copy, like
structuredClone, and == compares objects by identity (deepEqual compares contents).
class Box {
v: i64 = 1;
}
const a = new Box();
const b = a; // the same object
b.v = 2;
const c = a.clone(); // an independent deep copy
c.v = 3;
console.log(a.v, c.v, a == b, a == c); // 2 3 true falseAn object is freed the moment its last reference goes, together with everything only it refers to:
class Order {
items: string[] = [];
}
function checkout() {
const order = new Order(); // the only reference
order.items.push("tea");
console.log(order.items.length); // 1
} // freed here, deterministically
checkout();Calls pass the same object. Passing an object to a function costs nothing: the function sees the caller's object, so its changes are visible to the caller, as in JavaScript:
class User {
name: string = "ann";
visits: i64 = 0;
}
function greet(u: User): string {
return `hi ${u.name}`;
}
function recordVisit(u: User) {
u.visits++; // the caller sees this, as in JS
}
const user = new User();
console.log(greet(user)); // `user` stays usable
recordVisit(user);
recordVisit(user);
console.log(user.visits); // 2What it costs. The compiler infers, for every parameter, whether the function only reads it, modifies it, or keeps it (stores or returns it), and for every value whether it ever has more than one owner. A value with a single owner, the common case and every hot loop in the benchmark suite, is moved with no reference count, exactly like Rust code. Only types whose values the program actually shares get a reference count: one word in front of the object and an increment when it is shared. Closures work the same way: a variable that a stored closure and its enclosing function both change lives in a small shared cell, so both see every change.
Mutation is inferred
You don't mark which parameters a function changes. The compiler infers which functions
modify their parameters and which methods modify this, across the whole program, and uses that
to optimize. One rule follows from it: a call may not modify a value that another of its
arguments can also reach.
function append(a: number[], b: number[]) {
for (const x of b) {
a.push(x);
}
}
const xs = [1, 2];
append(xs, xs);main.vlt:8:12: error: cannot use `xs` here: this call may modify it through another argument
--> main.vlt:8:8: may be modified through this argument
= note: a value the callee may modify must not be reachable through another argument of the same call; use a separate variable or `.clone()`That rule is what lets the compiler assume no aliasing for values with one owner, as Rust does,
and generate code that keeps values in registers. Two variables that refer to the same array
(const ys = xs; append(xs, ys)) are fine and behave as in JavaScript: that array type is then
reference-counted and the compiler makes no such assumption for it. Variables destructured
from distinct parts of one value are separate too: after const [a, b] = pair,
append(a, b) compiles; append(pair[0], a) does not
(Exclusive access).
Sharing across tasks
Data used by several tasks at once lives in shared(...): an atomically reference-counted
value, freed when the last reference goes away. A Mutex inside guards changes. See
Async and concurrency.
Cleanup runs at a known point
Because freeing is deterministic, a class can define [Symbol.dispose]() (TypeScript's cleanup
method) and rely on it running when the value is freed. using makes the point explicit: at
the end of the block, on every exit path, in reverse order.
class Connection {
addr: string;
constructor(addr: string) {
this.addr = addr;
}
[Symbol.dispose]() {
console.log(`closed ${this.addr}`);
}
}
function ping(addr: string): string {
using conn = new Connection(addr);
if (addr == "") {
return "no address"; // conn is closed here too
}
return `PONG from ${conn.addr}`;
} // conn is closed here
console.log(ping("db:5432"));
// closed db:5432
// PONG from db:5432await using does the same with an async [Symbol.asyncDispose]().
Reference cycles
Reference counting can't free a cycle: if a.next = b and b.next = a, neither count reaches
zero, and both objects stay allocated until the program exits. That includes an object holding
a closure that captured it (this.onChange = () => this.render()). Break the cycle by hand
(set one link to null) when you are done with such a structure, or hold the back-pointer in a
WeakRef, or the association in a WeakMap or WeakSet: they don't keep their targets
alive, and let go of them the moment the last ordinary reference goes (Node waits for a
garbage collection) (prelude). Planned: weak fields for
back-pointers (parent: weak Node | null) and a compile-time warning when a type can form a
reference cycle. There is no cycle collector.
Performance
Deterministic memory is part of how Velt matches Rust's speed. In the micro-benchmarks of
bench/RESULTS.md (LLVM release builds against rustc -O and Node 22
on an Intel i9-12900HK under Windows 11, best of 10, ±15% noise), Velt runs the hashmap
benchmark in 182 ms (Rust 287 ms with std's SipHash HashMap, Node 479 ms) and classes in
201 ms (Rust 293 ms with Box<dyn Trait>, Node 521 ms). In the bench/async suite an idle Velt process uses about 50 MB less than Node.