Skip to content

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.

05-shadowing.zolo
Playground
// 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.

09-scoping.zolo
Playground
// 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.

enespt-br