Scopes & Name Resolution¶
Incan is lexically scoped: a name refers to the nearest binding in the surrounding source structure.
Two rules matter most in day-to-day code:
- Incan has block scope: most indented blocks introduce a new scope.
- Incan’s “plain assignment” (
x = ...withoutlet/mut) is inferred:- If
xalready exists in an enclosing scope,x = ...is a reassignment (so it requiresxto bemut). - If
xdoes not exist yet,x = ...creates a new binding in the current scope.
- If
This page explains how those rules interact, and how to write clear, predictable code.
Mental model¶
Think of the compiler keeping a stack of scopes. When you enter a new syntactic region (module, function, block, …) it pushes a scope; when you leave it pops the scope. Name lookup walks from the top of the stack outward until it finds a match.
This diagram shows how the compiler resolves a name:
flowchart TB
Enter["Enter region (module / function / block)"] --> Push["Push new scope on the stack"]
Push --> Lookup["Resolve a name: start at top scope"]
Lookup --> Found{Found binding?}
Found -- "yes" --> Use["Use nearest binding"]
Found -- "no" --> Outer["Check next outer scope"]
Outer --> Found
Use --> Exit["Exit region"]
Exit --> Pop["Pop scope from the stack"]
Internally this is implemented by the compiler’s symbol table (lookup() searches outward; lookup_local() checks only
the current scope).
Coming from Python?
Two differences tend to surprise people:
- In Python,
if/for/whileblocks don’t create a new scope (so names can “leak” out of blocks). - Python resolves names with LEGB (Local → Enclosing → Global → Builtins), and has a key rule:
any assignment in a function makes that name local to the entire function, unless you declare it
global/nonlocal. In Incan, blocks are scopes andx = ...is inferred as “new vs reassign” by looking outward for an existing binding.
Coming from Rust?
The spirit is similar (lexical scopes, mut for reassignment), but the surface syntax differs:
- Incan’s
let x = ...is the explicit way to introduce a new binding in the current scope (often used for shadowing). - Plain
x = ...is context-sensitive: it either reassigns an existingmutbinding, or introduces a new immutable binding ifxdoesn’t exist yet.
What counts as a scope in Incan?¶
Here are the scopes Incan uses when resolving names.
Module scope (file scope)¶
Everything at top level in a file lives in the module scope:
import/from ... import ...bindingsconstbindings- top-level function definitions, models/classes/enums, etc.
For example:
from math import sqrt # module-scope binding
const PI: float = 3.14159 # module-scope binding
def area(r: float) -> float: # module-scope binding (function)
return PI * r * r # uses module-scope name
model Circle: # module-scope binding (type)
radius: float
def demo() -> float:
c = Circle(radius=2.0)
return sqrt(area(c.radius)) # `Circle`, `sqrt`, `area` resolved from module scope
In this example, the names sqrt, PI, area, and Circle are all resolved from the module scope.
Function / method scope¶
Each def ...: body is a function scope.
Methods also have a function-like scope and define self (and mut self) for the method body.
For example:
const PI: float = 3.14159 # module scope
def area_scaled(r: float, scale: float) -> float:
let base = PI * r * r # PI: module, r: parameter, base: local
return base * scale # scale: parameter
model Circle:
radius: float
def area(self) -> float:
return PI * self.radius * self.radius # self: method scope, PI: module scope
In these examples:
- Module-scope names:
PI,Circle,area_scaled - Function-scope names: parameters like
randscale, plus locals likebase - Method-scope names:
self(and anything you bind inside the method body)
Block scope¶
Bodies of these constructs are checked in a block scope:
if/elsewhileforifexpressions (treated as statement-like blocks in the current checker)- list/dict comprehensions (the loop variable is local to the comprehension)
So variables defined in such blocks do not “leak out” to the surrounding scope.
Bindings vs reassignment¶
Incan uses the same syntax x = value for both:
- creating a new binding, and
- reassigning an existing one.
The current rule is:
let x = ...always creates a new immutable binding in the current scope (this is how you intentionally shadow).mut x = ...always creates a new mutable binding in the current scope (a fresh binding, but mutable).- Plain
x = ...is inferred:- if
xalready exists in any enclosing scope, it is a reassignment (soxmust be mutable) - otherwise it creates a new immutable binding in the current scope
- if
mut controls reassignment¶
- A binding is immutable by default.
- You can only reassign a binding if it was declared
mut. - Shadowing an outer name is done with
let(a new binding in the inner scope).
Example:
def reassigns_outer() -> int:
mut x = 1
if true:
x = 2
return x # 2
Shadowing example (block-local):
def shadows_in_block() -> int:
let x = 1
if true:
let x = 2 # new binding in the block scope (shadows outer x)
return x # still 1
Notes on let:
let x = ...is the explicit form for “introduce a new binding in the current scope”.- It’s optional when introducing a name for the first time (plain
x = ...will do that ifxdoesn’t exist yet), butletis how you make shadowing unambiguous and readable in nested scopes. mut x = ...introduces a new mutable binding. Reassigning later uses plainx = ...(and requires thatxwas introduced withmut).
You get an error if you write this:
def errors_without_mut() -> Unit:
let x = 1
if true:
x = 2 # error: cannot reassign immutable variable 'x'
That’s because plain x = 2 is treated as a reassignment (since x already exists), and the outer x is immutable.
Prefer explicit let / mut¶
Use let/mut when you care about whether you’re shadowing or reassigning; it avoids surprises and matches what you
intended.
Example:
def shadow_vs_reassign() -> int:
mut x = 10
if true:
x = 11 # reassigns the *outer* x (because x already exists)
mut x = 12 # creates a new mutable block-local x (shadows outer x in this block)
x = 13 # reassigns the *inner* x (still in the block scope)
return x # still returns 11, as the x = 13 was assigned to a new x local to the if-block
Closures and capturing¶
Incan closures use arrow syntax:
add1 = (x) => x + 1
Closures introduce their own function scope (parameters are local to the closure body). Names from outer scopes can be read by normal lexical lookup.
Plain assignment inside a closure behaves the same as elsewhere: if the name exists already, it’s treated as a reassignment
(so it requires the outer binding to be mut). Shadow with let if you want a closure-local name.
Example:
def closure_capture() -> int:
mut x = 1
inc = () => x = x + 1 # reassigns outer x (so outer x must be mut)
inc()
return x # 2
Note: Incan does not expose Python-style
global/nonlocaldeclarations.
Common gotchas (and how to think about them)¶
-
“Why did
x = ...inside aniferror?”- If
xalready exists, plainx = ...is treated as a reassignment, soxmust bemut. - If you intended a new block-local
x, uselet x = ...in the block.
- If
-
“Why does
x = ...sometimes requiremut?”- Only reassignment requires
mut. Plain assignment is inferred: it reassigns ifxalready exists; otherwise it creates a new immutable binding.
- Only reassignment requires
-
“Where do imported names live?”
- Imports add names to the module scope (file scope). Inside functions, you can refer to them like any other outer binding.
See also¶
- Imports & Modules — module boundaries, exports (
pub), and importing names - Closures — arrow syntax and when to use closures