Skip to content

Top Pitfalls

The list of mistakes everyone hits at least once. Internalising these will save you a lot of red squiggles.

1. Forgetting -> None

Sync functions returning nothing still need the annotation.

def main(): # ❌
print("hi")
def main() -> None: # ✅
print("hi")

2. Writing x = 1 at function scope

Locals require let or mut.

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

Module-level bindings default to let.

3. Passing dict.get(k) somewhere expecting V

dict.get(k) returns V?. Narrow first.

let counts: dict[str, int] = {"a": 1}
let n: int = counts.get("missing") # ❌
let n: int = counts.get("missing", 0) # ✅ (default)

4. Putting def display(self) -> str inside class

Methods go in impl blocks.

class User:
id: int
def display(self) -> str: # ❌
...
# ✅
class User:
id: int
impl User:
def display(self) -> str:
return f"user {self.id}"

5. Writing __init__

Don’t. The constructor is generated. Use field defaults or a free function.

class User:
id: int
def __init__(self, id: int) -> None: ... # ❌

For framework bases that need __init__, use class!.

6. from typing import TypeVar

Use PEP 695 instead.

from typing import TypeVar
T = TypeVar("T") # ❌
def first[T](xs: list[T]) -> T?: ... # ✅

7. isinstance(x, MyInterface)

Rejected by default — PEP 544 only checks attribute presence, not signatures. Use static narrowing or refactor to a sealed union.

8. asyncio.create_task(...) for fire-and-forget

Use go f(x) instead — the runtime registry holds a strong reference.

9. lazy from numpy import array

Rejected at parse time. Use lazy import np = numpy plus np.array(...).

10. comptime let NOW: float = time.time()

The sandbox forbids time.*. Compute at runtime with lazy let or a plain function.

11. Returning early from a with-chain without an else err:

Fine — but only if the enclosing function returns a compatible Result. Without an else, the first Err short-circuits via the function’s return.

12. Empty container without annotation

let xs: list = [] # ❌ implicit Any element
let xs: list[int] = [] # ✅

13. Putting blocking I/O inside async def

Typhon doesn’t catch this (it’s a Python-wide hazard). Use aiofiles or asyncio.to_thread(...).

14. Treating bool as int

The checker treats them as distinct. Cast explicitly with int(b) / bool(n).

15. Mutating a frozen instance

class P frozen:
x: float
let p = P(x=1.0)
p.x = 2.0 # ❌

Construct a new instance.

16. class X(Base) frozen: ordering

The frozen modifier comes between the class name and the base list, not after it:

class Square(Shape) frozen: # ❌ does not parse
side: float
class Square frozen(Shape): # ✅
side: float

The same applies to generics — type parameters sit before frozen: class Stack[T] frozen:, class Stack[T] frozen(BaseStack):.

17. Unannotated *args / **kwargs

Rule 1 (every parameter annotated) extends to variadic parameters since v0.9.0. Canonical idiom for genuinely variadic functions is object:

def trace(f, *args, **kwargs): # ❌ tyc::missing_annotation on each
...
def trace[R](f: Callable[..., R], *args: object, **kwargs: object) -> R: # ✅
return f(*args, **kwargs)

18. func[T](args) for explicit type instantiation

Rejected at check time since v0.9.0. Let the binding type drive inference instead:

let p = pair[int](1, 2) # ❌ tyc::operator_type_mismatch
let p: tuple[int, int] = pair(1, 2) # ✅ T inferred from the binding
let empty: list[int] = first([]) # ✅ for inference-failing positions

19. ? inside a comprehension

Comprehensions lower to nested loops in Python, so the surrounding function frame ? would short-circuit out of is not the comprehension’s frame:

def collect(xs: list[str]) -> Result[list[int], str]:
return Ok([parse(x)? for x in xs]) # ❌ tyc::invalid_question_op

Pre-extract with an explicit loop, or chain .and_then / .map:

def collect(xs: list[str]) -> Result[list[int], str]:
mut out: list[int] = []
for x in xs:
out.append(parse(x)?)
return Ok(out)

20. freeze let X = SomeMutableClass()

The check pass validates the RHS of every freeze let since v0.9.0. Constructing a non-frozen user class on the RHS now fires tyc::freeze_not_freezable at check time instead of failing at first import:

class Counter: # not frozen
value: int
freeze let CFG = Counter(value=0) # ❌ tyc::freeze_not_freezable

Mark the class frozen, switch to a built-in container (list / dict / set wrap automatically), or use a plain let.

21. Heterogeneous variant inside a generic sealed union

Cons[T] flowing into LL[T] works since v0.9.0:

type LL[T] = Cons[T] | Nil[T]
def length[T](xs: LL[T]) -> int:
mut cur: LL[T] = xs
while True:
match cur:
case Nil(): return 0
case Cons(_, tail):
cur = tail # ✅ since v0.9.0 — was type_mismatch before

Before v0.9.0 the assignment fired tyc::type_mismatch because the variant wasn’t recognised as assignable to the parametric alias. The fix is automatic — rebuild against tyc ≥ v0.9.0.

Where next