Skip to content

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] "=" EXPR

Examples:

let x: int = 1
mut counter: int = 0
let xs = [1, 2, 3] # type inferred as list[int]
mut total = 0 # type inferred as int
let user, posts = (u, ps) # destructuring
let xs: list[int] = [] # empty container; annotation required

Rules

  1. Inside functions, every local binding declares let or mut.

    def f() -> None:
    x = 1 # ❌ tyc::missing_binding_kind
    let x: int = 1 # ✅
  2. let bindings cannot be reassigned or deleted. del x and an except … as x handler (which deletes x when it ends) count as assignments too.

    def f() -> None:
    let x: int = 1
    x = 2 # ❌ tyc::immutable_assign
    del x # ❌ tyc::immutable_assign
  3. mut bindings can be reassigned at the same type or a subtype.

    def f() -> None:
    mut x: int = 1
    x = 2 # ✅
    x = "two" # ❌ tyc::type_mismatch
  4. Module-level bindings default to let if neither keyword is present.

    PI: float = 3.14 # implicit let
    mut feature_flag: bool = False # explicit mut

    Locals do not default — the keyword is always required inside functions.

  5. Loop variables and with targets are not let / mut. They follow Python’s scoping rules and are implicitly rebindable each iteration / context entry.

  6. gather: bindings are immutable single-assignment (no let / mut keyword 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 int
let s = "hello" # inferred str
let xs = [1, 2, 3] # inferred list[int]
let mixed = [1, "two"] # infers list[int | str]; annotate when the union is unintentional
let empty = [] # infers list[Any] — annotate as list[T] to keep the element type honest

For 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: int
let 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 typed
let (a: int, b) = pair() # b's type inferred from the RHS
let (a, b) = pair() # both inferred

The 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_shadow

Pick 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: Cfg
match _load():
case Ok(v):
loaded = v
case Err(e):
return Err(e)
print(loaded.host) # ✅ definitely assigned on the only non-diverging arm

Sibling 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 + list

A 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 = 1
mut y: int = 2
y = y + 1

Where next