Shadowing and Scope
Shadowing lets you redeclare a name with a new let, creating a fresh
binding (the old one becomes unreachable) — useful for transformation pipelines
where the logical name stays the same but the type or value changes:
let value = "42" → let value = 42 → let value = value * 2; each let creates a new binding.
// Feature: Shadowing — redeclaring a name with `let`
// Syntax: `let x = ...` followed by another `let x = ...`
// When to use: transform a value in stages while keeping the same
// name (parse → validate → normalize), change a variable's type
// during a pipeline. Unlike `mut`, every `let` creates a NEW
// binding; the previous one becomes inaccessible.
// Shadowing with type change.
let value = "42" // str
let value = 42 // int (new binding)
let value = value * 2 // int = 84
print(value) // 84
// Useful for "cleaning" data in a pipeline.
let raw = " Hello "
let raw = raw.trim() // "Hello"
let raw = raw.to_upper() // "HELLO"
print(raw) // HELLO
// Shadowing inside blocks (scope).
let x = 10
{
let x = 100 // shadows only inside the block
print(x) // 100
}
print(x) // 10 (outer binding intact)
// Difference vs `mut`:
// - `let mut x = 1; x = 2` -> same binding, new value
// - `let x = 1; let x = 2` -> new binding, still immutable
let count = 1
let count = count + 1
let count = count + 1
print(count) // 3 (each `let` creates a fresh binding)
Lexical scope determines where a binding can be used. Every { ... } block
opens a new scope: bindings declared inside it die when } closes and do not
leak out. Functions, if, for, and while follow the same rule:
Inner bindings do not exist outside the block; if let restricts the binding to the true branch.
// Feature: Lexical binding scope
// Syntax: bindings live in the `{ ... }` they were declared in
// When to use: to understand when a variable "dies" and where it
// can be used. Zolo uses lexical scoping, like Rust and JS `let`.
// A nested block has its own scope.
let outer = "outside"
{
let inner = "inside"
print(outer) // outside — outer visible
print(inner) // inside
}
// inner does not exist anymore here (uncomment to test):
// print(inner) // error: undefined variable
print(outer) // outside
// Function scope — params and local bindings disappear on return.
fn compute(n: int) -> int {
let doubled = n * 2
let result = doubled + 10
return result
}
print(compute(5)) // 20
// `doubled` and `result` did not leak outside.
// `if`/`for`/`while` introduce a new scope.
var acc = 0
for i in 1..=3 {
let squared = i * i // lives only inside the loop
acc = acc + squared
}
print(acc) // 1 + 4 + 9 = 14
// `if let` bindings only apply inside the true branch.
let value: int? = 42
if let v = value {
print("inside: {v}") // v exists here
}
// v does not exist outside.
Challenge
In the shadowing example, replace the successive let declarations with a single
let mut and reassignments. The final output should be the same — compare both
approaches and decide which one makes the intent clearer.