05 — Immutability: let vs var

MiniLang distinguishes let (immutable binding) from var (mutable binding). This distinction is enforced by the interpreter after the resolver has already annotated depths.

Tracking mutability in the environment

The environment stores a mutability flag alongside each value:

struct Slot {
    Value value;
    bool  mutable_;
};
std::unordered_map<std::string, Slot> vars_;

define stores the slot:

void Environment::define(const std::string& name, Value v, bool mutable_) {
    vars_[name] = {std::move(v), mutable_};
}

set / setAt checks the flag:

void Environment::setAt(int depth, const std::string& name, Value v) {
    Environment* e = this;
    for (int i = 0; i < depth; ++i) e = e->parent_.get();
    auto& slot = e->vars_.at(name);
    if (!slot.mutable_)
        throw RuntimeError("Cannot reassign 'let' binding '" + name + "'.");
    slot.value = std::move(v);
}

The assignment check

When the interpreter visits an assignment expression:

Value Interpreter::visitAssign(AssignExpr& e) {
    Value v = evaluate(*e.value);
    assignVariable(e, v);
    return v;
}

assignVariablesetAt → immutability check. If the target was bound with let, a RuntimeError is thrown with a clear message. This is a runtime check, not a static one.

Why not static?

Making immutability a static error (checked by the resolver) would require tracking whether each name was declared as let or var in the scope stack. That's doable — the scope map could store {bool defined, bool mutable}.

The choice here is pragmatic: static checking is strictly better for user experience (error before running), but it requires threading the mutability flag through two more data structures. For the curriculum, a runtime check demonstrates the concept clearly. cp-05 introduces the type-checker pass which is a static pass and shows how static checks are structured.

Shadowing across scopes

let x = 1;
{
    var x = 2;   // OK — new binding in inner scope, different slot
    x = 3;       // OK — this x is mutable
}
print x;  // 1 — outer let x unchanged

Each let/var creates a new slot in its scope. Shadowing is allowed: a var x in an inner scope doesn't make the outer let x mutable. The resolver assigns separate depths to each, so the inner assignment never reaches the outer slot.

The let design in practice

In real languages:

  • Rust: let is immutable, let mut is mutable.
  • JavaScript: const is immutable, let is mutable (confusingly opposite to MiniLang).
  • Swift: let is immutable, var is mutable (same as MiniLang).
  • Haskell: Everything is let-bound and immutable by default.

MiniLang follows Swift/Rust semantics. The pedagogical point is that immutability is a property of the binding, not the value. A let binding to a mutable array still allows mutating the array's contents; it prevents rebinding the name to a different array.