Skip to content

Ownership, References, and Borrowing

Shape gives you Rust-level memory safety with Python-level ergonomics. The compiler enforces strict aliasing rules at compile time — no runtime overhead, no garbage collector pauses, no data races.

Shape has three binding keywords with distinct ownership semantics:

KeywordMutable?OwnershipWhen to use
letNoUniqueDefault. Immutable bindings.
let mutYesUniqueMutable locals, accumulators, loop counters.
varYesShared (copy-on-write)Shared mutable state, closures.
let x = 42 // immutable, uniquely owned
let mut count = 0 // mutable, uniquely owned — direct mutation
var shared = [1, 2, 3] // mutable, shared — copy-on-write on mutation

let and let mut bindings are uniquely owned. There is exactly one owner at any time, and assignment transfers ownership (a “move”):

let a = [1, 2, 3]
let b = a // MOVE — a is invalidated
// print(a) // ERROR: use of moved value

Because there is only one owner, let mut mutations happen directly with zero overhead — no coordination or copying required:

let mut items = [1, 2, 3]
items.push(4) // Direct mutation — single owner, no copy needed

var bindings use shared ownership. Multiple bindings can reference the same data, and mutations use copy-on-write: the data is cloned before mutation only when it has multiple references.

var data = [1, 2, 3]
let alias = data // Both reference the same data
data.push(4) // Copy-on-write: data is cloned, then mutated
// (SURFACE: v0.3 G.1 (b)-class §3.B.1 — `var` + alias + `.push` CoW path
// currently SEGFAULTs under JIT and produces a dev-mode auto-print of an
// internal future-ptr under VM. Documented contract preserved; gating
// item tracked at `docs/cluster-audits/v0.3-g1-step1-fundamentals.md`.)

var is essential when you need to share mutable state across closures or when multiple parts of your code reference the same value. For single-owner cases, prefer let or let mut for better performance.

Non-Copy types (strings, arrays, objects) follow ownership rules:

let a = [1, 2, 3]
let b = a // MOVE — a is invalidated
// print(a) // ERROR: use of moved value
let c = [4, 5, 6]
let d = clone c // CLONE — c stays valid
print(c) // OK: c is still valid

Copy types (int, number, bool, none) are implicitly copied — no borrow tracking needed.

A reference borrows a value without taking ownership.

let x = 42
let r = &x // shared reference to x
let mut arr = [1, 2, 3]
let m = &mut arr // exclusive (mutable) reference to arr

Two rules govern all borrowing:

  1. Any number of shared borrows (&) can coexist.
  2. At most one exclusive borrow (&mut), and it cannot overlap with any shared borrow.

While a value is borrowed:

  • The owner cannot be written while any borrow (shared or exclusive) is active.
  • The owner cannot be read while an exclusive borrow is active.
let mut x = 10
let r1 = &x // shared borrow
let r2 = &x // OK: multiple shared borrows
// let m = &mut x // ERROR B0001: can't take &mut while & exists
print(r1) // last use of r1, r2
let m = &mut x // OK: shared borrows ended (NLL)

The most common use of references is passing values to functions:

fn read_first(&arr) {
return arr[0]
}
fn append(&mut arr, value) {
arr.push(value)
}
let mut data = [1, 2, 3]
let first = read_first(&data) // shared borrow for the call
append(&mut data, 4) // exclusive borrow for the call

When you don’t annotate a parameter, the compiler infers the mode from usage:

fn total(items) {
// compiler sees items is only read → infers &items (shared)
var sum = 0
for item in items { sum = sum + item }
return sum
}

The LSP shows the inferred mode as an inlay hint: items: &[int].

Method calls create temporary borrows automatically:

let mut arr = [1, 2, 3]
arr.push(4) // desugars to (&mut arr).push(4)
arr.len() // desugars to (&arr).len()

References support several advanced patterns:

  • Disjoint field borrows work: &mut obj.a and &mut obj.b can coexist because the MIR solver tracks places at field granularity.
  • Index borrowing is supported: &arr[0] creates a reference into the array.
  • References in local containers: parameter borrows can be stored in local containers if the borrow provably outlives the container.
  • return &x for locals: detected by the MIR solver (no hard-coded rejection) — the solver proves the reference would escape the referent’s region.

References are scoped borrows — they cannot escape their referent’s region:

// ERROR: MIR solver detects reference would escape local's region
fn bad() {
let x = 42
return &x // x would be destroyed, reference would dangle
}
// OK: return the VALUE through a reference
fn read_val(&x) {
return x // returns the dereferenced value, not the reference
}

References interact with concurrency boundaries:

  • Detached tasks: all references (shared and exclusive) are rejected across detached task boundaries.
  • Structured tasks: only exclusive (&mut) references are rejected; shared (&) references are allowed because they are truly immutable and scope-bounded.

Shape uses non-lexical lifetimes — a borrow ends at its last use, not at the end of its scope:

let mut data = [1, 2, 3]
let r = &data // shared borrow starts
print(r[0]) // last use of r — borrow ENDS here
data.push(4) // OK: no active borrows

Without NLL, data.push(4) would fail because r is still “in scope.” NLL eliminates these false positives.

CodeMeaningCommon Fix
B0001Borrow conflict (& + &mut on same variable)Reorder code so borrows don’t overlap
B0002Write to owner while borrowedMove the write after the last borrow use
B0003Reference escapes its scopeReturn an owned value instead
B0004Unexpected & on non-reference parameterRemove the & — parameter is by-value

Every error includes the conflict location, a note pointing to the first borrow, and a suggested fix with a code diff.

Shape replaces Rust’s Send/Sync traits with three simple rules:

What crosses a task boundaryStructured taskDetached taskWhy
Owned value (move or clone)YesYesNo aliasing possible
&T (shared reference)YesNoStructured: scope-bounded; Detached: could outlive referent
&mut T (exclusive reference)NoNoWould create aliased mutation
let mut data = [1, 2, 3]
let snapshot = clone data // clone must be at let-init position (see Grammar)
async let result = process(snapshot) // OK: detached child gets owned snapshot
async let view = read_only(&data) // OK: shared ref in structured child
async let bad = modify(&mut data) // ERROR C0001: exclusive ref across task

For shared mutable state across tasks, use Mutex<T>, Atomic<T>, or channels.

// Immutable binding
let x = value
// Mutable binding
let mut x = value
// Auto-inferred binding
var x = value
// Shared reference
let r = &x
fn f(&param) { ... }
// Exclusive reference
let m = &mut x
fn g(&mut param) { ... }
// Explicit move
let y = move x
// Explicit clone
let y = clone x

See Ownership Deep Dive for the full specification including the MIR, Datafrog solver, repair engine, and concurrency primitives.