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 = valueis 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/forblocks 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 = valuefirst looks for an existing binding ofx, 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
xuses the same nearest-binding search; an unresolved read isE0301. - A
forloop 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 byprint(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
forvariable 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
resolverpass 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 theUnboundLocalErrorsurprise is a classic beginner trap. letfor 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/forbodies. 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.