Loft gives you two separate ways to protect a value from accidental changes:
1. Compile-time const: mark a variable or parameter with const and the compiler refuses to compile the writes that word forbids. The check happens before the program even runs — zero runtime cost.
2. Runtime lock: the #lock attribute on a reference lets you lock a store at runtime so that any write attempt panics immediately, even across function boundaries. This is useful for debugging: turn it on when you suspect an unexpected mutation.
struct Counter {
value: integer
}
The two places const can go
A name has a slot, and the slot holds a value. Those are two different things to freeze, so const has two positions and they mean opposite halves:
- 'const' before the NAME —
const x = …— freezes the slot. You can never pointxat something else, but the value it holds stays mutable: you may still append to it and write its elements and fields. This is the builder shape — set it up once, fill it in place. - 'const' before the TYPE —
x: const T— freezes the value. You may swap in a whole new value, but you can never reach into the one that is there. This is the read-only shape — a shared table, a parameter a function promises not to touch.
Write both — const x: const T — and the name is fully immutable.
A plain number or boolean has no interior separate from its slot, so for those the two positions mean the same thing: both freeze it completely.
const before the TYPE is what a function uses to promise "I will not modify this". Every write through the parameter is a compile error: appending to it, writing an element, writing a field, or writing a field of a field.
fn read_value(self: const Counter) -> integer {
self.value
}
A parameter without const can write, and the write reaches the caller.
fn increment(self: Counter) {
self.value += 1
}
fn main() {
const local variables
const before the name is the common case for a local: a configuration value or a lookup table that should never be pointed somewhere else deep inside a long function.
const limit = 100;
assert(limit == 100, "const integer is readable");
For a whole number the two positions agree, so this one is frozen outright: limit = 200 and limit += 1 are both compile errors.
A const local holding a collection is the builder shape — the slot is write-once, the contents keep growing.
const totals = [1, 2, 3];
totals += [4];
totals[0] = 9;
assert(len(totals) == 4, "a const local still appends: {len(totals)}");
assert(totals[0] == 9, "a const local still takes an element write");
The other position freezes the contents instead, and leaves the slot free.
frozen: const vector<integer> = [1, 2, 3];
frozen = [7, 8];
assert(len(frozen) == 2, "a value-const local still re-points: {len(frozen)}");
frozen += [9] would not compile: the value is read-only.
The same split applies to a struct.
const cfg = Counter {value: 42 };
assert(cfg.value == 42, "const reference is readable");
assert(read_value(cfg) == 42, "const passed to const param");
Runtime store locks with #lock
const is checked once, when the program is compiled. A lock is checked on every write, while the program runs — so it catches a write the compiler cannot see coming, wherever in the program it happens.
#lock is an attribute on any reference variable: a struct, a vector, any value that lives in a store. A freshly created one starts unlocked.
d = Counter {value: 5 };
assert(!d#lock, "new store starts unlocked");
d#lock = true;
assert(d#lock, "store is locked after assignment");
Reading a locked store is still fine — only writes are blocked.
assert(read_value(d) == 5, "locked store is still readable");
A write is not. d.value = 6 panics here, and so does increment(d) — the guard is on the store, so it fires wherever the write is written. That is the point of the lock: the program stops at the write instead of carrying a value someone changed behind your back.
Set it back to false to lift the guard.
d#lock = false;
increment(d);
assert(d.value == 6, "unlocking lets the write through again");
When to use each approach
- Use
conston parameters and locals to express your design intent and get compile-time safety at zero cost. - Use
#lock = truewhen you want a runtime tripwire: you suspect some code path is mutating a value it should not touch, and you want the program to stop at that write rather than corrupt silently.
get_store_lock() is the function form of the #lock attribute. Both return the same boolean.
e = Counter {value: 99 };
assert(get_store_lock(e) == e#lock, "function form matches attribute before lock");
e#lock = true;
assert(get_store_lock(e) == e#lock, "function form matches attribute after lock");
}
Run it yourself
This page is a loft program. It runs here, in your browser — press Run, then ask it something.
Stop on a line
// Copyright (c) 2024-2026 Jurjen Stellingwerff
// SPDX-License-Identifier: LGPL-3.0-or-later
// @NAME: Store Locks
// @TITLE: Immutability with const and runtime store locks
// Loft gives you two separate ways to protect a value from accidental changes:
//
// 1. **Compile-time const**: mark a variable or parameter with 'const' and the
// compiler refuses to compile the writes that word forbids. The check happens
// before the program even runs — zero runtime cost.
//
// 2. **Runtime lock**: the '#lock' attribute on a reference lets you lock a
// store at runtime so that any write attempt panics immediately, even across
// function boundaries. This is useful for debugging: turn it on when you
// suspect an unexpected mutation.
struct Counter {
value: integer
}
// ## The two places 'const' can go
// A name has a slot, and the slot holds a value. Those are two different things
// to freeze, so 'const' has two positions and they mean opposite halves:
//
// * **'const' before the NAME** — `const x = …` — freezes the **slot**. You can
// never point `x` at something else, but the value it holds stays mutable:
// you may still append to it and write its elements and fields. This is the
// builder shape — set it up once, fill it in place.
// * **'const' before the TYPE** — `x: const T` — freezes the **value**. You may
// swap in a whole new value, but you can never reach into the one that is
// there. This is the read-only shape — a shared table, a parameter a function
// promises not to touch.
//
// Write both — `const x: const T` — and the name is fully immutable.
//
// A plain number or boolean has no interior separate from its slot, so for those
// the two positions mean the same thing: both freeze it completely.
// 'const' before the TYPE is what a function uses to promise "I will not modify
// this". Every write through the parameter is a compile error: appending to it,
// writing an element, writing a field, or writing a field of a field.
fn read_value(self: const Counter) -> integer {
self.value
}
// A parameter without 'const' can write, and the write reaches the caller.
fn increment(self: Counter) {
self.value += 1
}
fn main() {
// ## const local variables
// `const` before the name is the common case for a local: a configuration
// value or a lookup table that should never be pointed somewhere else deep
// inside a long function.
const limit = 100;
assert(limit == 100, "const integer is readable");
// For a whole number the two positions agree, so this one is frozen outright:
// `limit = 200` and `limit += 1` are both compile errors.
//
// A const local holding a collection is the builder shape — the slot is
// write-once, the contents keep growing.
const totals = [1, 2, 3];
totals += [4];
totals[0] = 9;
assert(len(totals) == 4, "a const local still appends: {len(totals)}");
assert(totals[0] == 9, "a const local still takes an element write");
// The other position freezes the contents instead, and leaves the slot free.
frozen: const vector<integer> = [1, 2, 3];
frozen = [7, 8];
assert(len(frozen) == 2, "a value-const local still re-points: {len(frozen)}");
// `frozen += [9]` would not compile: the value is read-only.
//
// The same split applies to a struct.
const cfg = Counter {value: 42 };
assert(cfg.value == 42, "const reference is readable");
assert(read_value(cfg) == 42, "const passed to const param");
// ## Runtime store locks with #lock
// `const` is checked once, when the program is compiled. A lock is checked on
// every write, while the program runs — so it catches a write the compiler
// cannot see coming, wherever in the program it happens.
//
// `#lock` is an attribute on any reference variable: a struct, a vector, any
// value that lives in a store. A freshly created one starts unlocked.
d = Counter {value: 5 };
assert(!d#lock, "new store starts unlocked");
d#lock = true;
assert(d#lock, "store is locked after assignment");
// Reading a locked store is still fine — only writes are blocked.
assert(read_value(d) == 5, "locked store is still readable");
// A write is not. `d.value = 6` panics here, and so does `increment(d)` —
// the guard is on the store, so it fires wherever the write is written.
// That is the point of the lock: the program stops at the write instead of
// carrying a value someone changed behind your back.
//
// Set it back to false to lift the guard.
d#lock = false;
increment(d);
assert(d.value == 6, "unlocking lets the write through again");
// ## When to use each approach
// * Use 'const' on parameters and locals to express your design intent
// and get compile-time safety at zero cost.
// * Use '#lock = true' when you want a runtime tripwire: you suspect some
// code path is mutating a value it should not touch, and you want the
// program to stop at that write rather than corrupt silently.
// get_store_lock() is the function form of the #lock attribute.
// Both return the same boolean.
e = Counter {value: 99 };
assert(get_store_lock(e) == e#lock, "function form matches attribute before lock");
e#lock = true;
assert(get_store_lock(e) == e#lock, "function form matches attribute after lock");
}