let and mut
let and mut declare local binding kinds. They are soft keywords in the lexer; the parser attaches a Mutability field to the resulting Assign node.
Syntax
binding ::= ("let" | "mut") NAME [":" TYPE] "=" EXPRExamples:
let x: int = 1mut counter: int = 0let xs = [1, 2, 3] # type inferred as list[int]mut total = 0 # type inferred as intlet user, posts = (u, ps) # destructuringlet xs: list[int] = [] # empty container; annotation requiredRules
-
Inside functions, every local binding declares
letormut.def f() -> None:x = 1 # ❌ tyc::missing_binding_kindlet x: int = 1 # ✅ -
letbindings cannot be reassigned or deleted.del xand anexcept … as xhandler (which deletesxwhen it ends) count as assignments too.def f() -> None:let x: int = 1x = 2 # ❌ tyc::immutable_assigndel x # ❌ tyc::immutable_assign -
mutbindings can be reassigned at the same type or a subtype.def f() -> None:mut x: int = 1x = 2 # ✅x = "two" # ❌ tyc::type_mismatch -
Module-level bindings default to
letif neither keyword is present.PI: float = 3.14 # implicit letmut feature_flag: bool = False # explicit mutLocals do not default — the keyword is always required inside functions.
-
Loop variables and
withtargets are notlet/mut. They follow Python’s scoping rules and are implicitly rebindable each iteration / context entry. -
gather:bindings are immutable single-assignment (nolet/mutkeyword needed inside the block — the desugarer treats them as fresh local bindings).
Type inference
If a let / mut omits the annotation, the type is inferred from the RHS:
let x = 1 # inferred intlet s = "hello" # inferred strlet xs = [1, 2, 3] # inferred list[int]let mixed = [1, "two"] # infers list[int | str]; annotate when the union is unintentionallet empty = [] # infers list[Any] — annotate as list[T] to keep the element type honestFor nullable RHS (e.g. dict.get), the inferred type is nullable:
let x = some_dict.get("k") # inferred int?Destructuring
let q, r = divmod(17, 5) # q: int, r: intlet x, y = point # x: float, y: float (from tuple[float, float])let head, *rest = [1, 2, 3, 4] # head: int, rest: list[int]*rest follows Python’s semantics. All destructured names take the let/mut of the enclosing keyword.
Annotated tuple unpacking
For typed destructuring, the per-element form annotates each name individually (v0.3.1):
let (a: int, b: str) = func(x, y) # both elements typedlet (a: int, b) = pair() # b's type inferred from the RHSlet (a, b) = pair() # both inferredThe outer-annotation form is also accepted (v0.8.0):
let (a, b): tuple[int, str] = pair()Both forms parse under tyc check and tyc run since v0.9.0 — the typed-element form was previously rejected by the VM’s pre-parse, so multi-file projects could split on which path they ran.
*args and **kwargs annotation policy
Variadic parameters are subject to Rule 1 (every parameter annotated) since v0.9.0. For genuinely variadic functions (typically generic decorators), the canonical idiom is object:
def trace[R](f: Callable[..., R], *args: object, **kwargs: object) -> R: return f(*args, **kwargs)object is the honest spelling for “any value” at the boundary; the body still needs to narrow with isinstance to do anything type-specific.
Shadowing
Typhon does not allow shadowing a binding with another let /
mut. Python is function-scoped, so a re-declaration would silently
rebind the outer binding rather than introduce a new one in a block —
and the checker fires tyc::no_block_shadow on attempts to do so:
def f() -> None: let x: int = 1 let x: str = "two" # ❌ tyc::no_block_shadowPick a fresh name (x2, result, …) or declare the original as mut
and reassign it if the type stays the same. This is the conservative
default — Rust-style block-scoped shadowing may return once the
language has block scopes of its own, but it is not in v0.7.0.
Sibling if/elif branches are not affected: from v0.7.0 the resolver
no longer fires tyc::no_block_shadow for same-named let bindings
declared in sibling arms (the bindings are independent and never both
in scope at the same point). See Project Status for the full v0.7.0 changelog.
Declare-only let NAME: T
From v0.7.0 a let binding may be declared without an initialiser
when every subsequent control-flow path assigns to it exactly once
before it is read. The classic motivating shape is splitting a
Result-returning load across match arms:
let loaded: Cfgmatch _load(): case Ok(v): loaded = v case Err(e): return Err(e)print(loaded.host) # ✅ definitely assigned on the only non-diverging armSibling match arms and sibling if / elif / else bodies each
count as a separate first-assignment path; the resolver snapshots the
uninit-span set per arm and unions the initialisations afterwards.
return / raise / continue / break mark a branch as diverging
and the analysis excludes it from the intersection. Loops do not
propagate assignments out (the body may execute zero times).
Reads on a path that didn’t assign fire tyc::use_of_uninitialised
with labels on both the use site and the declaration. Any second
assignment to a declared-only let still fires
tyc::immutable_assign — the first assignment IS the initialiser,
not a free reassignment.
freeze let
let locks the binding; freeze let also locks the value. It is allowed at
module level only, and the value is deep-frozen when the module loads:
list becomes tuple, dict becomes a read-only MappingProxyType (a
hashable builtin frozendict on a 3.15+ [python] target), set becomes
frozenset, recursively. An instance of a non-frozen class is
rejected at check time (tyc::freeze_not_freezable); any other value with no
immutable equivalent raises TypeError when the module loads.
Frozen binding types
The annotation on a freeze let describes the input value. The binding
itself has the frozen shape: list[T] becomes tuple[T, ...],
dict[K, V] becomes Mapping[K, V] and set[T] becomes frozenset[T],
and nested container elements are frozen too. Reads stay available; mutation
is rejected.
class Point frozen: x: int y: int
freeze let HOSTS: list[str] = ["a", "b"]freeze let LIMITS: dict[str, list[int]] = {"port": [80, 443]}freeze let ORIGIN = Point(x=0, y=0)
def main() -> None: let first: str = HOSTS[0] # ✅ reads work let more: tuple[str, ...] = HOSTS + ("c",) # ✅ tuple concatenation let ports: tuple[int, ...] = LIMITS["port"] # ✅ the nested list is a tuple HOSTS.append("c") # ❌ no `append` on a tuple LIMITS["port"].append(8080) # ❌ same, one level down let bad = HOSTS + ["c"] # ❌ tuple + listA new binding that receives a frozen value keeps the frozen shape
(let alias = HOSTS then alias.append(...) is rejected too). A mutation
deliberately wrapped in a handler for the exception it raises
(try: CFG["k"] = 1 / except TypeError:) is reported as a warning instead
of an error, so a program can probe the runtime failure.
Instances of frozen classes pass through unchanged and keep their identity.
Their fields are not rebuilt or deep-frozen: give the class immutable field
types when the instance must be deeply immutable.
Emit
let and mut are erased at emit time:
let x: int = 1mut y: int = 2y = y + 1x: int = 1y: int = 2y = y + 1Where next
- Values and Bindings (tour) — the teaching page.
- Binding Errors (diagnostics) —
missing_binding_kind,immutable_assign, etc.