Memory model
There is no garbage collector, and nothing ever pauses the program: memory is freed
deterministically when the last reference to it goes away. The compiler infers ownership and
mutation, so programs carry no lifetime, borrow or mut annotations.
Values and references
Values that copy
Numbers, bool, strings (Strings), literal and
enum types, and unions, tuples and T | null of those. A tuple is copied when it is
assigned or stored, but a function or callback that changes an element of a tuple it was
passed (t[1] = 0) changes the caller's tuple, as in JS. The objects a tuple holds are
shared, not copied: pick()[0].x = 5 changes the object in the tuple pick returned. A
write into an element of a tuple that a call or a conditional gives (m.get(k)![1] = 2) is
an error, since it would change a copy.
- Objects are references, as in JS: class instances, arrays, maps, structs, object types
(
type P = { x: i64 }, object literals) and closures.const b = a;, passingato a function, storing it in a field, an array or a map, returning it and capturing it all refer to the same object:b.push(1)changesa. Elements, fields andMap.getresults are the stored objects themselves: changing the objectm.get(k)returns changes the one in the map.x.clone()makes an independent deep copy (likestructuredClone).
No garbage collector, no pauses
Memory is freed (and [Symbol.dispose]() runs,
below) the moment the last reference goes. The
compiler infers ownership: a value with a single owner is moved, with no reference count and
the same code as Rust, and only types whose values the program actually shares get a
reference count (a word in front of the object; arrays and object types that are shared are
then stored behind a pointer). Freeing is deterministic. An array type iterated through an
Iterable<T> (a value or a bound: sum(xs) with sum(xs: Iterable<i64>)) counts as shared
for the whole program, since its iterator holds the array; nothing else about it changes.
- Naming an object again inside one function does not share it:
const me = this, or a closure that capturesthisand is only called where it is created (Captures), refers to the same object without a count (unless a conflict elsewhere in the program makes them share; see Captures).
Calls borrow
Passing an object to a function lends it, so log(user); save(user); costs
nothing. The compiler infers per parameter whether the callee reads it, modifies it, or keeps
it. A parameter the body stores or returns takes ownership: a caller that does not use its
variable again hands it over for free, one that does shares it. Returning a field of a class
instance (a getter's return this.ctl.sig) shares the field and only borrows the instance.
Passing a field of an object other variables also refer to (decode(this.db.data, out))
borrows it for free when the callee runs no code that could replace it while reading it
(it reads, computes, pushes plain values onto arrays it is given, and throws through a
never function such as corrupt("bad header"); a method called on it, this.db.header(n),
counts as such a callee too); a callee that may (it calls a callback or a method a subclass
overrides) gets the field shared for the call, so it stays alive whatever the callee does.
A local that only reads such a field (const data = this.db.data;) borrows it the same way
in a release build: it takes no count when nothing that runs while it is used could free it.
- A promise is shared like an object: every holder may
awaitit and gets the result (Async). - A view shares the bytes of the array it views:
subarray(begin, end)of au8[](Node'sbuf.subarray) is aUint8Arrayover the array's bytes, so writes through either are seen by both, and the view keeps the array alive (in a field, returned, captured, across anawait) and sees it after it grows. Unlike a NodeBuffer, au8[]can become shorter (length = 0,pop,splice); the view then has the elements the array still has in its range:lengthcounts them, an index past them panics, a write past them does nothing, and growing the array back brings them back (prelude). Eachsubarrayhas its ownbufferobject over the same array (in Nodeb.subarray(0, 8).buffer === b.subarray(4, 12).buffer). - Dropping a long chain of objects never overflows the stack, however it is linked: a
linked list through a
next: Node | nullfield or a tree throughleftandrightis freed in a loop (when the class is not part of a class hierarchy, with no base class and no subclasses, and no field declared after the link can run a[Symbol.dispose]()), and any other chain (through arrays,Mapvalues, closures, interface values, subclasses, struct values or a recursive object type such asinterface Node { next?: Node }) is freed in nested steps up to a fixed depth (128 levels of such nesting), with the objects past it freed when the outer drop is done (order of cleanup). - Reference cycles (
a.next = b; b.next = a) are never freed, and that includes an object holding a closure that captured it (this.onChange = () => this.render()in a constructor or method: the closure refers to the object, the object to the closure); replace the field (this.onChange = () => {}) when the object is done to free both. Planned:weakfields and a compile-time warning for reference cycles. WeakMap,WeakSetandWeakRef(prelude) don't keep their keys and targets alive: an object's entries go when its last reference does, at once (Node waits for a garbage collection). A weak map whose value refers back to its key, such as a cache from an object to a wrapper holding it (cache.set(raw, new Proxy(raw))), frees both once nothing else refers to either, like JavaScript. Not freed: a value graph of more than 256 objects, a key that is also in an ordinary cycle (raw.self = raw), and two maps whose entries keep each other's keys (m1.set(a, b); m2.set(b, a)), which stay until an entry is deleted or a map goes. Releasing an object of a type that can be a weak key, or be reached from a weak map's keys and values, costs two more instructions when it frees the object; other types are unaffected.- Evaluation order is JS's: see Evaluation order below.
- An object literal nothing else refers to is stored inside its holder: see Object literals inside their holder below.
class Box {
v: i64 = 1;
}
function bump(b: Box) {
b.v += 1;
}
const a = new Box();
const b = a; // the same object
b.v = 5;
bump(a);
const copy = a.clone(); // a deep copy
copy.v = 0;
console.log(a.v, b.v, copy.v); // 6 6 0
console.log(a == b, a == copy); // true false: `==` compares objects by identityEvaluation order
Evaluation order is JS's: operands and arguments run left to right, and one read before a later
operand's call keeps the value it had (f(o.v, o.change()) passes the old o.v). The target of
an assignment is evaluated before its right-hand side: a call at its root runs once, first
(get().v = f(), m.get(k)!.v = f()).
An assignment or compound assignment to a field of a class object, or of an object literal
another name refers to (const keep = o.inner), reached through a place writes to the object its
target named before the right-hand side ran: in o.inner.v = f() or o.inner.v += f(), when f
replaces o.inner, the old object gets the value and the new one keeps its own, as in JS. This
holds however deep the target is (o.a.b.c.v = f() when f replaces o.a and something else
still refers to the old o.a). An old object nothing else refers to is freed by f, and the
write is dropped with it.
Known differences: when f frees the old object and then installs a new one at the same
place (o.inner = null; o.inner = new P()), the allocator may give the new object the freed
address; the write then goes to the new object, where Node writes to the unreachable old one;
the same holds for an array element whose array the right-hand side replaces.
Object literals inside their holder
An object literal nothing else refers to is stored inside its holder (an object literal or a
class object), so an assignment into it whose right-hand side may replace it is an error: in o.inner.v = f() with
o = { inner: { v: 1 } }, where f (or a function it calls) assigns a new object to o.inner
or to o, and no other name refers to an object of that type, Node writes to the old object,
which nothing can see any more, and Velt would write to the new one. The fix computes the value
first, which runs the same in TypeScript: const v = f(); o.inner.v = v;.
The check looks at what the right-hand side may run: reads, arithmetic and new values compile as
written, and so do calls that assign neither the field holding the object (that field of that
object type: assigning b.vel does not replace b.pos) nor one holding a holder above it. What
a call may run counts as follows:
- A callback written in the call (
rows.map((r) => r.n)) or held in a constant counts as itself,nullas nothing, and a function's own callback parameter as what its callers pass, unless the function is itself used as a value (fs.map((f) => f())); any other function value counts as any function the program uses as a value. - A method call through a base class or interface counts as every method of that name, and an
awaitas anything in the program. - A generic function's field of type
Tcounts as the type the call passes forT, or, where that is not known, as any type the program passes as a type argument.
Code that runs without being called counts as called:
- a class's
toString(),toJSON(),[util.inspect.custom]()and ownclone()when the right-hand side formats, serialises or copies a value that may hold that class (String(x),[x].join(),JSON.stringify,console.log,x.clone()); - its
[Symbol.dispose]()wherever such a value is dropped; - the body of any generator in the program at each
next(),for...ofor spread over a generator; - initializers evaluated where they are used.
A variable of the function that no closure captures (not a parameter) is replaced only by an
assignment in the right-hand side itself, so a function that reassigns a variable of its own
(s = apply(s, t)) does not count.
Mutation is inferred
There is no mut; writing it is an error
(`mut` is not needed: mutation is inferred). Over the whole program the compiler infers:
- Methods that modify
this: directly, through a field, through a callee or a closure, on any path. An overridden method and its overrides share the answer, as do an interface method's implementations. - Parameters whose contents are modified (field writes,
push, modifying methods, passing them on). The caller sees those changes; reassigning the parameter only rebinds the local name. Number, bool and string parameters are local copies. - Callbacks receive objects by reference and may modify them; calling a function value of unknown behavior counts as modifying the call's other arguments.
- Not allowed: modifying a module constant through a call
(
cannot modify module-level constant `X`) and reassigning an object parameter of a closure or of an overridden or interface method.
class Cart {
items: string[] = [];
}
function addItem(cart: Cart, item: string) {
cart.items.push(item); // modifies `cart`: the caller sees it
}
function bump(n: i64) {
n += 1; // a copy: local change only
}
const cart = new Cart();
addItem(cart, "tea");
let n = 1;
bump(n);
console.log(cart.items, n); // [ 'tea' ] 1Exclusive access
A value that a call may modify (a modified parameter, the receiver of a modifying method, a
place a closure argument modifies) must not be reachable through any other argument of the same
call: directly, as an overlapping place (a / a.f, xs / xs[i]), through a for...of
binding, or as a closure capture. append(xs, xs), append(g.rows[0], g.rows[1]) and
xs.forEach((x) => { xs.push(x); }) are errors
(cannot use `xs` here: this call may modify it through another argument). Reading a number
is fine (xs.push(xs.length)), and distinct fields are disjoint (f(this.a, this.b)). So are
variables destructured from distinct parts of one value: the elements of a tuple or of an array
pattern and the fields of an object (const [a, b] = t; append(a, b), or
for (const [name, input] of cases) it(name, () => check(input))), while each still overlaps
the value it came from (append(t[0], a) is an error). This rule is what lets the compiler
assume no aliasing for values with a single owner, like Rust.
An argument may change the inside of a class instance or an array another argument passes while
it is evaluated, before the call starts (this.set(this.src.get(1)) with get changing
this.src): JavaScript's callee sees that change too. The change must be to a place inside the
passed value (show(b, (b.items[0] = 8)) is allowed), or set the length of an array a field
holds (g(h.vs, (h.vs.length = 0)): g sees the shorter array). A system function's buffer is
not passed so: it is a view of the array's bytes, which a later argument may not change at all
(pread(fd, h.b.subarray(0, 8), (h.b.length = 0) as i64) is an error,
System functions). A change through the passed value
itself is still an error: an argument that hands xs or b to a modifying call
(xs.push(popIt(xs)), or show(b, setFirst(b)) where setFirst only sets b.items[0]), and
so is replacing it or a place on its way (f(x.arr, (x.arr = []))). Changing an object of an
object type or a struct another argument passes (f(o.pt, (o.pt.x = 5)) with
pt: { x: number; y: number }) is an error too: Velt holds those inline and passes a copy,
which would miss the change.
Two variables that refer to the same object (const ys = xs; append(xs, ys)) are allowed and
work as in JS: the type is then reference-counted, and the compiler makes no such assumption for
it. So is a closure argument that takes over a variable another argument only borrows
(derive(parts, () => parts.length) where derive keeps the closure): the closure shares it.
The parameters of a closure (or of a function used as a value) may be one object, since a call
through a function value can pass the same object twice. Such a closure may pass two of them on
whole to another closure, which is checked the same way (mount(vnode, container) calling
mountComponent(vnode, container) in a renderer whose nested functions use its variables).
Passing them to a named function that modifies one of them is still an error. So is a part of
one with another when the check sees the callee modify one
(mountComponent(vnode.kids[0], container)). When it does not (a closure or function value
whose modified parameters are borrowed: work(a, b.log) where work replaces x.log and fills
ys), the call compiles, and with a === b the callee fills the array that replaced the one it
was given, unlike JS.
A function that modifies a parameter and is also used as a value gets the caller's object for
it. When a later argument may replace that object first (it calls a function value that
replaces the field passed first, as in pushes[0](p.log, swaps[0](p))), the parameter is the
object the argument was when it was evaluated, as in JS: the object's type is then
reference-counted. A later argument that cannot reach the object does not do that: a literal, a
standard-library call on numbers and strings (Math.sqrt(4), x.toFixed(2)); nor does any
later argument when the object is a variable of the caller's own that no closure assigns and the
call's arguments do not assign: a function value called there cannot replace it
(hooks[0](acc, nums[0]())).
The key of a Map's or Set's has, get or delete (and a WeakMap's or WeakSet's) is
compared by identity, so it may be the object the collection is reached through: in
for (const link of sub.deps) link.dep.subs.delete(sub) the call reads only sub's address. A
key inside the collection itself is still an error.
m.with(f) gives f the locked value; when m is a shared handle, that value is not part of
m's place, so a callback may read what sits next to it
(this.m.with((v) => { v.n += this.step; })), while one that changes the object holding m is
still an error.
function append(a: i64[], b: i64[]) {
for (const x of b) {
a.push(x);
}
}
const xs: i64[] = [1, 2];
append(xs, xs); // error: this call may modify `xs` through another argumentResource cleanup: using and [Symbol.dispose]
A class or struct may define [Symbol.dispose](): void (TypeScript's cleanup method name). It
runs automatically when the last reference to the object goes away (end of scope, overwrite,
owner dropped), before its fields are dropped, like Rust's Drop. Calling it explicitly,
x[Symbol.dispose](), releases x right there (the hook runs once, when no other reference
remains; using x afterwards is use of moved value `x` ). A method merely named dispose()
is an ordinary method. APIs with an explicit close() keep it, like Node.
class TempFile {
path: string;
constructor(path: string) {
this.path = path;
}
[Symbol.dispose]() {
console.log("removing", this.path);
}
}
{
const t = new TempFile("a.tmp");
console.log("using", t.path);
} // prints "removing a.tmp" here
console.log("after");using x = …; (TypeScript 5.2) declares a const that is disposed at the end of the
enclosing block: in reverse declaration order, and also on return, break, continue and
throw. The value's type must have [Symbol.dispose](); a null value is skipped. A using
variable stays put until then: passing it to a function that borrows it is fine, and so is
an async call awaited where it is made (await x.read()), but moving it away (returning it,
storing it, a stored or returned promise of an async call on it, spawn, an explicit
x[Symbol.dispose]()) is an error; declare it with const for that. using is only allowed inside blocks.
class Lock {
name: string;
constructor(name: string) {
this.name = name;
}
[Symbol.dispose]() {
console.log("unlock", this.name);
}
}
function transfer(fail: bool): string {
using a = new Lock("a");
using b = new Lock("b");
if (fail) {
return "aborted"; // unlock b, unlock a
}
return "done"; // unlock b, unlock a
}await using x = …; (in async functions) awaits x[Symbol.asyncDispose]() at the end of
the block, in the same order as using and on the same exits. Without [Symbol.asyncDispose]
it falls back to [Symbol.dispose], like TypeScript. [Symbol.asyncDispose] must be async,
take no parameters and return Promise<void>. Like every async method it takes this by
value, so it consumes the value (a [Symbol.dispose] hook of the same type runs when it
returns). An error thrown by the cleanup propagates from the block.
class Conn {
addr: string;
constructor(addr: string) {
this.addr = addr;
}
request(line: string): string {
return `${this.addr}: ${line}`;
}
async [Symbol.asyncDispose]() {
await sleep(1); // e.g. flush and say goodbye
console.log("closed", this.addr);
}
}
async function ping(addr: string): Promise<string> {
await using conn = new Conn(addr); // closed at the end of the block
return conn.request("PING");
}Order of cleanup in long chains
When an object goes away, its [Symbol.dispose]() runs first, then its fields are released in
declaration order, each completely (an object a field held is disposed with everything it
holds) before the next. A chain through the class's own field (next: Node | null) keeps
exactly this order at any length (each node is disposed before the rest of the chain) when that
field is declared after every field whose drop can run a [Symbol.dispose](), and the class
is not part of a class hierarchy (no base class and no subclasses); otherwise it is released in nested steps like the chains below. In a chain
or tree that nests through other values (arrays, Map values, closures, interfaces, recursive
object types), an object more than 128 such levels below the one being released is set aside
and released, in the order it was reached, once the outer release has finished everything
else. Every hook still
runs exactly once, and a chain is still disposed from its head on; only a branch deeper than
128 levels is disposed after the shallower objects that come after it.
class Step {
name: string;
next: Step[] = [];
constructor(name: string) {
this.name = name;
}
[Symbol.dispose]() {
console.log("dispose", this.name);
}
}
{
const a = new Step("a");
const b = new Step("b");
b.next.push(new Step("c"));
a.next.push(b);
a.next.push(new Step("d"));
} // dispose a, dispose b, dispose c, dispose dThe Disposable / AsyncDisposable interfaces and DisposableStack (a using value needs a
class or struct type with the method), using in for (… of …) heads, and calling
[Symbol.dispose]() through an interface or a type parameter (the value's own drop would run
the hook again).