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 = 2Or:
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 # okfor 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 bodyEnforced 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 referencedimport 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 _: passFix: 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 accClass 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 acctyc::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 2print([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 bindingprint([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 literalFix: 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 finishesCPython 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 initialisedFix: 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
letandmutreference — the keyword spec.- Values and Bindings (tour) — teaching page.
[strictness] unused-import— severity knob.