Skip to content

Binding Errors

tyc::missing_binding_kind

A local binding has no let or mut:

def f() -> None:
name = "Alice" # ❌

Fix: add let (immutable, default) or mut (mutable).

def f() -> None:
let name: str = "Alice"

Module-level bindings default to let; only locals (function and method bodies) require the keyword.

tyc::immutable_assign

Reassigning a let binding:

def f() -> None:
let x: int = 1
x = 2 # ❌

Fix: declare mut instead, or pick a different name for the second binding:

def f() -> None:
mut x: int = 1
x = 2

Or:

def f() -> None:
let x: int = 1
let x2: int = 2 # fresh name; Typhon rejects shadowing within a function

(Note: unlike Rust, Typhon does not allow let-shadowing. Python is function-scoped, so a let x inside a nested if / for / with would still rebind the outer x. The checker fires tyc::no_block_shadow for both the same-scope and nested-scope cases.)

Inside a loop body

A let in a loop body is freshly bound each iteration, so a later sibling loop may reuse the same scratch name — but reassigning it from inside the loop that declared it (or a loop nested within that one) is still an error:

for a in xs:
let t: int = a # ok
for b in ys:
let t: int = b # ok — a different loop, a fresh binding
for a in xs:
let t: int = a
t = 99 # ❌ same loop body

Enforced since v1.0.0-alpha.7; before that the sibling-loop carve-out swallowed both cases.

Through global / nonlocal

global NAME / nonlocal NAME names a binding in an outer scope, and assigning through the keyword is an assignment to that binding — so let applies:

let CONFIG: str = "a"
def go() -> None:
global CONFIG
CONFIG = "b" # ❌

Fix: declare it mut CONFIG: str = "a" at module level if it is meant to be rebound. Also enforced since v1.0.0-alpha.7; before that the assignment silently created a separate function-local binding, leaving the module constant unchecked and clobbered at run time.

Through del and except … as

del NAME removes a binding, and so does except E as NAME (Python deletes the name when the handler ends). Either one would make a let vanish, and a later read would raise NameError / UnboundLocalError, so both count as an assignment to the let:

let LIMIT: int = 3
def report(raw: str) -> None:
let err: str = "none"
try:
print(int(raw))
except ValueError as err: # ❌ the handler would delete `err`
print("bad input")
del LIMIT # ❌

Fix: use a fresh name for the handler (except ValueError as exc:), or declare the binding mut if it is meant to be deleted. A for or with … as target may still rebind a let.

tyc::unused_import

An imported name is never used in the module:

import json # ❌ never referenced
import os # used below
def f() -> str:
return os.getcwd()

Fix: remove the import, or prefix it with _ if it is intentionally unused.

Severity is configurable via [strictness] unused-import:

  • "warn" (default since v0.8.0; was "error" before) — flagged but doesn’t fail the build.
  • "error" — fail the build on unused imports. Set this for CI strictness.
  • "off" — no check.

The LSP exposes a “Remove unused import” code action that fires on these diagnostics.

tyc::pattern_shadows_outer

A case pattern captures a name that already exists as an immutable let in an enclosing scope. The capture would silently rebind the outer name inside the arm, which is almost always a typo for an intended class pattern.

let radius: float = 100.0
match shape:
case Circle(radius): # ❌ tyc::pattern_shadows_outer — captures `radius`,
# silently rebinding the outer `let radius`
print(radius)
case _:
pass

Fix: rename the pattern capture, or write the keyword form to be explicit:

match shape:
case Circle(radius=r): # ✅ keyword-pattern, no shadowing
print(r)

The diagnostic was documented from v0.3.0 onwards but only got a firing site in v0.8.0. Sibling case / if / elif arms each get fresh-binding behaviour (v0.6.0+), so the diagnostic only fires on genuine shadow situations.

tyc::mutable_default_param

A mutable literal or constructor used as a function parameter default is created once and shared across every call — the classic Python def f(x, acc=[]) footgun, where the same list accumulates state between calls:

def append_id(x: int, acc: list[int] = []) -> list[int]: # ⚠️ shared default
acc.append(x)
return acc

Class fields with mutable defaults get the default_factory rewrite (see tyc::class_attr_shadows_slot); parameters can’t be rewritten the same way, so they get this warning instead.

Fix: default to None and build the mutable inside the body:

def append_id(x: int, acc: list[int] | None = None) -> list[int]:
if acc is None:
acc = []
acc.append(x)
return acc

tyc::loop_closure_capture

A closure created inside a loop that references the loop variable observes the loop variable’s final value at call time, not the value at the iteration where the closure was created ([lambda: i for i in range(3)] produces three closures that all return 2):

let fns = [lambda: i for i in range(3)] # ⚠️ each lambda returns 2
print([f() for f in fns]) # [2, 2, 2], not [0, 1, 2]

The lambda i=i: default-binding idiom and immediately-invoked closures are exempt — those capture the per-iteration value, so they don’t fire.

Fix: bind the loop variable per-iteration via a default argument, or build the closure in a factory:

let fns = [lambda i=i: i for i in range(3)] # ✅ per-iteration binding
print([f() for f in fns]) # [0, 1, 2]

tyc::is_literal_comparison

s is "x" (or is 5, is (1, 2), …) compares identity against a literal, not equality. The result depends on CPython’s interning of small ints and strings, so it’s almost never what’s intended — CPython itself raises a SyntaxWarning for this form.

def is_yes(s: str) -> bool:
return s is "yes" # ⚠️ identity comparison against a literal

Fix: use == for value comparison.

def is_yes(s: str) -> bool:
return s == "yes"

tyc::possibly_unbound

Warning. An ordinary function-local name is read on a path that may not assign it — or provably never does.

def first_word(s: str) -> str:
if s:
word = s.split()[0]
return word # ⚠ `word` is not assigned on every path that reaches here
def after_handler() -> str:
try:
int("x")
except ValueError as e:
pass
return str(e) # ⚠ `e` is unbound once its `except ... as` handler finishes

CPython raises UnboundLocalError / NameError on the path that skipped the assignment. The same definite-assignment pass that drives tyc::use_of_uninitialised (an error, for declare-only let NAME: T bindings) tracks every local: assignments are intersected across if / match / try arms, discarded across loop bodies that may run zero times, and a del or the end of an except ... as handler unbinds the name again. Reads inside nested functions, lambdas and comprehension bodies are not checked.

Fix: give the name a default before the branch, loop or try, or return in the arm that has nothing to assign. Advice-level — the build continues.

tyc::no_block_shadow

A let / mut inside a nested block re-declares a name the function already bound. Python has no block scope, so the inner declaration would rebind the outer name rather than create a new one.

def main(flag: bool) -> None:
let x: int = 1
if flag:
let x: int = 2 # ❌ cannot shadow `x` — Typhon names are function-scoped
print(x)

Fix: give the inner value its own name (let inner: int = 2), or make the outer binding mut and assign to it. Sibling branches (if / elif / else, separate case arms) may each declare the same name.

tyc::use_of_uninitialised

A declare-only let NAME: T is read on a path where nothing assigned it.

def pick(cond: bool) -> int:
let x: int
if cond:
x = 5
return x # ❌ `x` may be used before it is initialised

Fix: assign on every path that reaches the read (add the else: x = 10 arm), or initialise at the declaration. Branches that return, raise, break or continue do not need to assign; loop bodies never count, since they may run zero times. Ordinary locals get the advice-level tyc::possibly_unbound instead.

tyc::missing_initialiser (not currently emitted)

Listed by tyc explain --list, but no check in the current compiler emits it. A declare-only let NAME: T is legal: the first assignment on each path is its initialiser, a read before that reports tyc::use_of_uninitialised, and a declaration that is never assigned or read produces no diagnostic.

tyc::empty_collection_no_annotation (warning)

An empty [], {} or set() is bound without an annotation, so its element type is unknown and later element-type mistakes go unchecked.

def main() -> None:
let xs = [] # ⚠ empty list literal `[]` without a type annotation
print(xs)

Fix: annotate the binding: let xs: list[int] = [].

tyc::shadowing_in_same_scope (reserved)

Future-reserved diagnostic for confusing shadowing patterns. Not yet emitted by the compiler. A let x followed by another let x in the same scope is currently accepted (Typhon treats it as legitimate shadowing). The diagnostic name is reserved so the eventual rule has a stable code from day one.

Where next