hanki

12. Mutability and visibility

x = 5         # immutable
var y = 0     # mutable
y = y + 1     # OK

value, not a reference: q = p is an O(1) copy of the binding, and a later change to one never affects the other - there is no aliasing (unlike Python or JS). Because a value cannot mutate in place, a field write p.x = v (or a nested p.a.b = v) rebinds p to an updated copy of the struct. It therefore requires p to be a var; writing through a plain = binding or a parameter is rejected (H0207). No other binding that aliased the old value observes the change - q below keeps its old field:

struct Point
  x: i32
  y: i32
end

def main!() -> ()
  var p = Point(x=1i32, y=2i32)
  q = p             # copy of the binding
  p.x = 9i32        # rebinds p; q is untouched
  assert!(p.x == 9i32)   # p sees its own update
  assert!(q.x == 1i32)   # q keeps the old value - no aliasing
end

(a linked list, a tree), but no sequence of writes can make a value reference itself, even transitively: a field write's right-hand side is evaluated before the write, so it holds at most a copy of the old value, and a constructor's arguments are evaluated before the aggregate they build exists. Every attempt to close a cycle just nests one more finite level. To model a genuinely cyclic structure (a graph, a doubly-linked list), name nodes by key in a Map<Id, Node>, or let actors reference each other - an ActorRef is an identifier, not a traced pointer, so actor reference cycles are fine. (Erlang gives its terms the same guarantee.) Acyclicity is a semantic guarantee the runtime leans on: every structural walk (==, cmp, hash, rendering, the mailbox deep copy) terminates on a finite value, and per-actor heap reclamation is plain reference counting - complete with no cycle detector, on both tiers. A value is freed at the instant its last handle disappears (which is also what makes resource close deterministic, §4), and there is no tracing collector and no collection pause.

open option

struct Node
  id: i64
  next: Option<Node>
end

# Total: values are acyclic, so the chain always ends.
def len(n: Node) -> i64
  match n.next
    Some(rest) -> 1i64 + len(rest)
    None -> 0i64
  end
end

def main!() -> ()
  var x = Node(id=1i64, next=None)
  y = Node(id=2i64, next=Some(x))   # y.next holds x's value
  x.next = Some(y)                  # rebinds x - ties no knot
  assert!(len(x) == 2i64)           # x -> y -> old x; the chain ends
end

field at a time would allocate each iteration. A compile-time uniqueness (escape) analysis elides the copy to an in-place write where p is provably unshared at the write - an accumulator loop that builds a struct it never aliases out (no q = p, no passing it to a call/send/closure, no return p) stays allocation-free, matching Rust's mutate-through-owner and Python's mutate-object. The analysis is flow-sensitive: a write whose value escapes only later (e.g. the struct is built field-by-field and then returned) still mutates in place, while a write whose binding may be shared - including via a loop back-edge - keeps the copy. This is a pure optimisation: the observable value semantics above are identical either way, so the two execution tiers stay in lockstep and the cost of any un-elided copy is visible in the allocation profile, never a silent correctness gap. Where the static proof fails (an aliased-then-dead binding, a shape the analysis rejects), single-level p.x and two-level p.a.b writes on typed builds fall back to a runtime uniqueness check: a root whose handle count is 1 at the write mutates in place, anything genuinely shared copies. Deeper chains and comptime evaluation always copy. To see which writes the compiler could not prove, run hanki check --explain-copies: it reports each field write lowered with the runtime check or the copy (H0564, non-blocking lint advice) - the message says why (the root may be shared, or the target is nested) and adds an O(n) note when the write is inside a loop, so an accumulator that allocates because its root stays aliased is easy to spot. An actor's state fields are the one genuinely mutable place - a field write on a struct-typed state field rebinds that field the same way.