Mutability and Constants
This proposal has been accepted in principle.
It is currently under active development.
Parts might be incomplete or missing in Zirric.
Introduction
This proposal replaces the let keyword introduced in ZE-001 with two distinct keywords — const for immutable bindings and var for mutable bindings — to make mutability explicit at the declaration site. extern let becomes extern const for the same reason.
ZE-001 did not specify mutability rules in sufficient detail, leaving parameter mutability, the semantics of reassignment, and the interaction between bindings and reference types as undefined behaviour. This proposal closes those open ends.
Function parameters are treated as constants and do not require a keyword.
Motivation
ZE-001 introduces let as the declaration keyword for variables and includes an example showing reassignment (x = 2). The keyword let does not communicate whether a binding is constant or mutable — readers must scan all subsequent uses of the name to determine that. The keyword const, by contrast, directly describes the behavior: the binding holds a fixed value.
The same applies to extern values: extern const void communicates that void is a fixed, runtime-provided constant, while const makes that intent unambiguous without additional context.
Explicit mutability is a small syntactic cost that yields significant readability and analysis benefits: readers immediately know whether a name can change, and tooling can enforce immutability where declared.
Because ZE-001 is still in progress and reassignment of let bindings has not yet been implemented in Zirric, this is the right moment to make the change before the mutable form of let is ever shipped.
Proposed Solution
Remove let and introduce two declarations in its place, splitting the single let into a constant form and a mutable form:
| Declaration | Mutability | Allowed position |
|---|---|---|
const x = v |
Binding is fixed — x = is forbidden; member and subscript mutation is still allowed |
Local scope, module scope |
var x = v |
Binding is rebindable — x =, x.f =, x[i] = are all allowed |
Local scope, module scope |
extern const name |
Immutable, runtime-provided — replaces extern let |
Module scope only |
There is no extern var because the runtime owns those bindings.
Function parameters are implicitly constant. No keyword is written. ZE-001 did not specify parameter mutability; this proposal establishes that parameters are always const.
const x = 42 // x cannot be rebound
var counter = 0 // counter can be rebound
counter = counter + 1 // ok
x = 2 // compile-time error: x is const
data Person { name, age }
const person = Person("John", 40)
person.age = 42 // ok — mutates the field; the binding person is not rebound
person = Person("Jane", 30) // compile-time error: person is const
fn increment(n) { // n is implicitly const
n = n + 1 // compile-time error: n is const
return n
}
Extern constants look natural now:
extern const void
Detailed Design
Syntax
decl_const = [attr_chain], "const", identifier, "=", expression ;
decl_var = [attr_chain], "var", identifier, "=", expression ;
decl_extern_const = "extern", "const", identifier ;
decl = [attr_chain], ( decl_const | decl_var | decl_fn | decl_union | decl_data | decl_attr | decl_extern_type | decl_extern_const | decl_extern_fn | decl_mod ) ;
The discard pattern _ = expression continues to work unchanged. _ is special syntax and not a valid identifier; _[0] = x and _.field = x are not valid forms.
Mutability rules
- A
constbinding prevents rebinding the name:x = vis a compile-time error after declaration. Member assignment (x.field = v) and subscript assignment (x[i] = v) are still permitted because they mutate the referenced object, not the binding itself. - A
varbinding allows rebinding and all forms of mutation. - Function parameters behave like
constbindings.
These rules govern binding reassignment only — they are enforced syntactically by the compiler by tracking which identifier was declared with which keyword. They say nothing about the values themselves.
Value mutation through aliasing is a separate, semantic concern. All values — including data, Array, and Dict — are reference types. Any binding, const or var, that refers to the same object will observe mutations made through any other binding:
const arr = []
var arr2 = arr
arr2[0] = 99 // ok
// arr[0] is now 99 — arr and arr2 refer to the same object
data Person { name, age }
const p = Person("Alice", 30)
var p2 = p
p2.age = 99 // ok
// p.age is now 99 — p and p2 refer to the same object
Subscript assignment
This proposal introduces subscript assignment for Array and Dict. Subscript assignment mutates the collection object directly; the binding itself is not rebound, so it is permitted on both const and var bindings:
var items = [1, 2, 3]
items[0] = 99 // ok
const frozen = [1, 2, 3]
frozen[0] = 99 // ok — the binding is not rebound
frozen = [4, 5, 6] // compile-time error: frozen is const
Array and Dict are extern types. Subscript assignment is a dedicated operation on those types, distinct from the field assignment defined for data types below.
Member assignment
Field assignment on data values uses in-place mutation semantics: the field is updated directly on the existing object. No new instance is created. Like subscript assignment, the binding itself is not rebound, so field assignment is permitted on both const and var bindings:
data Person {
name
age
}
var person = Person("John", 40)
person.age = 42 // ok
person = Person("Jane", 30) // ok — var binding can be rebound
const frozen = Person("Jane", 30)
frozen.age = 99 // ok — field mutation does not rebind frozen
frozen = Person("Bob", 20) // compile-time error: frozen is const
Compound assignment operators +=, -=, *=, /=, %= desugar to a read followed by a write on the same lvalue:
person.age += 2 // equivalent to: person.age = person.age + 2
items[0] -= 1 // equivalent to: items[0] = items[0] - 1
The lvalue is resolved once: subscript expressions and member accesses in the chain are not evaluated twice. Compound assignment on a const binding’s direct name is still forbidden (x += 1 where x is const is a compile-time error), but compound assignment through a member or subscript is allowed.
extern types do not support field assignment. Array and Dict support subscript assignment as described above.
Chains
All mutation forms propagate in place through the entire chain. The only restriction is direct rebinding of a const root binding.
Given:
data Person {
name
age
}
data PersonList { friends } // friends is an Array of Person
var personData = PersonList([Person("Alice", 30)])
const pd = PersonList([Person("Alice", 30)])
All three forms work on both var and const root bindings:
// Field on data — mutates in place; permitted on const
personData.friends = []
pd.friends = []
// Subscript on Array — evaluates pd.friends[0] and replaces the element
personData.friends[0] = Person("Bob", 25)
pd.friends[0] = Person("Bob", 25)
// Chained field + subscript — evaluates personData.friends[0] and sets its age field directly
personData.friends[0].age = 99
pd.friends[0].age = 99
// Only direct rebinding of the root is forbidden for const
personData = PersonList([]) // ok — var
pd = PersonList([]) // compile-time error: pd is const
personData.friends[0].age = 99 evaluates personData.friends[0] to obtain a reference to the Person object, then sets its age field directly on that object. No new instance is created.
EBNF
stmt_assign = lvalue, [ augmented_op ], "=", expression ;
lvalue = identifier
| lvalue, "[", expression, "]"
| lvalue, ".", identifier ;
augmented_op = "+" | "-" | "*" | "/" | "%" ;
Module-scope declarations
Module-scope const and var declarations are evaluated lazily — a binding is initialised on first access. Circular dependencies between module-scope declarations do not produce a compile-time error; they produce a runtime error when the cycle is first encountered.
Module-scope var bindings may be read and reassigned by any function declared in the same module.
var requestCount = 0
fn trackRequest() {
requestCount = requestCount + 1
}
Closures and upvalues
var bindings that are captured by a closure (function literal or func defined in the same scope) must be allocated as upvalues — heap-allocated cells shared between the enclosing scope and all capturing closures. This ensures that mutations performed inside a closure are visible to the outer scope and vice versa.
Multiple closures capturing the same var binding share the same upvalue cell, so mutations made by one closure are immediately visible to all others:
var x = 0
const inc = { -> x = x + 1 }
const get = { -> x }
inc()
inc()
get() // 2 — both closures operate on the same cell
const bindings captured by a closure capture the current reference at closure creation time; no upvalue cell is needed.
var total = 0
const add = { n ->
total = total + n // captures `total` via upvalue
}
add(1)
add(2)
// total == 3
Attributes
Both const and var declarations may carry attribute chains, just as let did:
@Int
const maxRetries = 5
@String
var greeting = "Hello"
Breaking changes
let is removed from the language. Because ZE-001 is still in progress and let reassignment has not yet been implemented, no existing code relies on mutable let bindings. The migration is therefore straightforward for current users: every let declaration becomes either const or var, and every extern let becomes extern const.
| Old syntax | New syntax |
|---|---|
let x = v |
const x = v or var x = v |
extern let name |
extern const name |
The transformation is mechanical:
extern let→extern constlet name = vwherenameis never reassigned →const name = vlet name = vwherenameis intended to be reassigned →var name = v
Since reassignment is not yet in use, in practice all current let declarations map directly to const.
Changes to the Standard Library
- All
extern letdeclarations in the standard library (e.g.,extern let voidinprelude/shim.zirr) becomeextern const. - All
letbindings in.zirrsource files are migrated toconstorvaras appropriate. - The
decl_letEBNF rule in the language reference is replaced bydecl_const,decl_var, anddecl_extern_const, and the top-leveldeclproduction is updated accordingly. ArrayandDictgain subscript assignment support.
Alternatives Considered
Keep let as immutable, add var for mutable
This is the Swift/Kotlin model. It would be less disruptive (no let removal), but ZE-001 already documents that let values may be reassigned, so keeping let as immutable would still be a breaking change without the benefit of a more descriptive keyword. Removing let entirely is cleaner.
Keep let, add mut let or let mut
Adding a modifier avoids a new keyword but produces verbose syntax (let mut x = 0) and does not address extern let. The two-keyword approach (const/var) is more conventional and more readable.
Copy-on-set semantics for data types
An alternative to uniform in-place mutation is to give data types copy-on-set semantics: person.age = 42 would instead be equivalent to person = Person(person.name, 42), creating a new instance and reassigning the binding. Array and Dict would keep in-place mutation semantics.
This matches the model of languages like Swift (structs) and Elm (records). However, because all types in Zirric are reference types, copy-on-set for data would be a deliberate departure from the general object model and would create a confusing inconsistency: personData.friends = [] would update personData (copy-on-set), but personData.friends[0] = x would not (in-place mutation), even though the two lines look nearly identical. Uniform in-place mutation avoids this inconsistency.
Acknowledgements
- The
const/vardichotomy is well-established in Go and JavaScript (ES6). - The upvalue mechanism for mutable captured variables follows the design used in Lua and the Lua-derived Crafting Interpreters implementation by Robert Nystrom.