Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

11. Bare assignment, lexical block scope, nearest-binding rule

  • Status: Accepted
  • Date: 2026-09-02

Context

How are variables introduced and where do they live? Options for introduction: a declaration keyword (let/var/const), or bare assignment (x = 1 both creates and updates). Options for scope: function scope with hoisting (old JavaScript), block scope, or dynamic scope.

The project owner chose bare x = 5 — no let/var. That settles introduction and forces a decision about what a bare assignment means when an outer scope already has that name.

Decision

  • x = value is the only binding form. No declaration keyword.
  • Scope is lexical, and only functions introduce a scope. The global level is one scope; each function body (with its parameters) is a scope. if / elif / else / while / for blocks do not — a name first assigned inside one of them stays visible after it, in the enclosing function or global scope.
  • Nearest-binding rule: evaluating x = value first looks for an existing binding of x, searching the current function scope then each enclosing function scope out to the global scope.
    • Found ⇒ that binding is updated, wherever it lives.
    • Not found ⇒ a new binding is created in the current scope.
  • Reading x uses the same nearest-binding search; an unresolved read is E0301.
  • A for loop variable is an ordinary binding in the enclosing scope; after the loop it holds the last value iterated.
  • Closures capture by reference to the binding, so a closure can mutate a variable from an enclosing function.

There are no global / nonlocal keywords — the nearest-binding rule makes "assign to the outer x" the default when an outer x exists.

Consequences

  • No keyword, no "declared but unused" ceremony, closures-that-mutate just work.
  • Function-only scoping matches Python and most scripting languages, so it holds no surprises: if cond:\n result = ... followed by print(result) works.
  • You cannot shadow an outer variable by assigning the same name in an inner block — the inner assignment updates the outer one. To get a fresh variable, use a fresh name. This is the deliberate "one obvious way" tradeoff and the opposite of Python's local-by-default rule.
  • A typo in an assignment target that happens to match an outer name silently mutates that name. The resolver can warn on suspicious cases later; not in M1.
  • Closures over a for variable all see its final value (the well-known Python behaviour), because the variable is one binding, not one per iteration. If per-iteration capture is wanted, wrap the body in an immediately-called function. This may get a dedicated fix later.
  • A future bytecode VM will want per-use slot/depth resolution; the resolver pass is where that goes when the time comes (ADR 0004).

Alternatives considered

  • Python's rule (assignment always creates a local unless global/nonlocal). Enables shadowing but needs two extra keywords to write to an outer scope, and the UnboundLocalError surprise is a classic beginner trap.
  • let for new bindings, = for update (Swift-ish). Clear, but the owner explicitly wants no declaration keyword, and it's two forms for one idea.
  • Block scope for if / while / for bodies. Cleaner in theory (a loop temp does not outlive the loop), but it makes the extremely common "compute a value in a branch, use it afterwards" pattern fail, which is a worse surprise than the one it prevents. Rejected.
  • Function scope with hoisting. Rejected outright — a known mistake.