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:

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 knowIn Lumina
TypeScript const x / let xx = ... / mut x = ...
Rust let / let mutthe same split, with no &mut at call sites
Go / Python (everything mutable)add mut where state actually changes
Rust &mut T parametermut name: T parameter
structuredClone / .clone().copy()

Next: Errors & Types