Mutability
Lumina makes the safe choice the short one. A binding is immutable unless you write mut, and code that can change your data says so in its signature.
Const by default
name = "Ada" // cannot be reassigned
mut attempts = 0 // can be
attempts += 1
If you try to reassign an immutable binding, you get a compile error, not a lint:
error[E3009]: main.lumina: cannot reassign immutable binding `count` (at 3:5)
= help: declare it with `mut count = ...` to allow mutation
This is the reverse of TypeScript's let and Python's plain assignment, and the same as Rust's let / let mut. You will rarely type mut, and each one you do type marks a place where state actually changes.
Module-level constants use const with a type:
const MAX_RETRIES: Integer = 3
Mutating methods: mut self
A method that changes its struct takes mut self. A method that only reads takes self.
struct Counter {
public count: Integer
public function increment(mut self): Void {
self.count = self.count + 1
}
}
mut c = Counter { count: 0 }
c.increment()
c.increment() // c.count == 2
You can only call increment on a mut binding. A reader of the code can tell which calls can modify c.
Mutable parameters work by reference
When a function wants to change something the caller owns, the parameter is marked mut. The caller's value is then updated in place. You do not need a &mut sigil or a lifetime.
function addAll(mut items: Array<Integer>, extra: Integer): Void {
items.push(extra)
}
mut xs = [1, 2]
addAll(xs, 3) // xs is now [1, 2, 3]
Without the mut, the compiler refuses the mutation inside the function:
error[E3009]: main.lumina: cannot call mutating method `push` on immutable binding `items` (at 2:15)
= help: declare it with `mut items = ...` to allow mutation
So mut in a signature means exactly one thing: "this function may change what you pass in." A function without mut parameters cannot change its arguments, so you can call it without defensive copies.
Explicit copies
If you want an independent value instead of a shared one, ask for it:
mut xs = [1, 2, 3]
snapshot = xs.copy()
xs.push(4)
// xs.length() == 4, snapshot.length() == 3
All the snippets on this page come from one program that was compiled and run:
const MAX_RETRIES: Integer = 3
struct Counter {
public count: Integer
public function increment(mut self): Void {
self.count = self.count + 1
}
}
function addAll(mut items: Array<Integer>, extra: Integer): Void {
items.push(extra)
}
function main(console: Console): Integer {
name = "Ada"
mut attempts = 0
while attempts < MAX_RETRIES {
attempts += 1
}
mut c = Counter { count: 0 }
c.increment()
c.increment()
mut xs = [1, 2]
addAll(xs, 3)
snapshot = xs.copy()
xs.push(4)
console.println(`{name} {attempts} {c.count} {xs.length()} {snapshot.length()}`)
return 0
}
Ada 3 2 4 3
What about copy-on-write?
Earlier design documents described implicit copy-on-write for values. That design has been superseded, and Lumina does not implement implicit CoW designed. The model is:
- Immutable by default. Values you don't mark
mutare never changed out from under you. mutmeans by reference. Changes are visible to the caller, and the signature announces them..copy()when you want a copy. The cost is visible in the source.- Frozen types for configuration-style data.
@Memory.frozenstructs reject field assignment entirely (error E2030). See Memory.
This keeps the performance model readable: no hidden copies on a hot path and no reference counts behind your back.
Deep const
Immutability reaches through aliases: a value bound without mut cannot be changed through another binding that shares it in progress. @Memory.frozen makes the same promise for a whole type.
Coming from...
| You know | In Lumina |
|---|---|
TypeScript const x / let x | x = ... / mut x = ... |
Rust let / let mut | the same split, with no &mut at call sites |
| Go / Python (everything mutable) | add mut where state actually changes |
Rust &mut T parameter | mut name: T parameter |
structuredClone / .clone() | .copy() |
Next: Errors & Types