Python Habits to Drop
Patterns that are idiomatic Python but produce friction in Typhon.
”It worked before I added types”
Adding types reveals bugs that were always there. A T? parameter that was passed None 1% of the time was always crashing — the checker just told you about it. Don’t suppress the diagnostic; fix the underlying flow.
Optional[T] from typing
Spell it T?.
from typing import Optionaldef f(x: Optional[int]) -> None: ... # ❌def f(x: int?) -> None: ... # ✅tyc migrate rewrites this automatically.
Union[A, B]
Spell it A | B. The migrator handles this.
dict.get(k) as if it returned V
Default to non-nullable Python is the wrong intuition. dict.get(k) returns V?; either provide a default (dict.get(k, default)) or narrow.
Catching exceptions for control flow
try: return cache[key]except KeyError: return compute()Refactor to:
let cached: V? = cache.get(key)if cached is not None: return cachedreturn compute()Or use defaultdict.
Bare class for things that need validation
If the data crosses a trust boundary, use model. class is for fully-trusted internal types.
from typing import TypeVar and friends
PEP 695 syntax is the Typhon form. The migrator handles this.
isinstance(x, MyInterface)
Static narrowing or sealed unions. Read Interfaces for why.
Sticking assert isinstance(x, T) to narrow
assert isinstance(x, str)x.upper()Works, but if isinstance(x, str): ... or guard ... reads better and the checker treats them identically. Note that assert x is not None also narrows since v0.9.0 — the Python static-checker idiom is fully supported.
Reaching for Any to type variadic forwarders
Pre-v0.9.0 *args / **kwargs were lenient about annotations, so the loose Python idiom of def trace(f, *args, **kwargs): was tolerated. Rule 1 now extends to variadic parameters too. Use object for “really any value”, not Any:
def trace(f, *args: Any, **kwargs: Any) -> object: # ⚠️ Any silently drops checks ...
def trace[R](f: Callable[..., R], *args: object, **kwargs: object) -> R: # ✅ return f(*args, **kwargs)object is the honest spelling — the body still has to narrow with isinstance to do anything type-specific.
Writing f[int](xs) to pin a generic
Python’s runtime does let you subscript a generic function (it just returns the function back unmodified), so you might be used to spelling type arguments at the call site. Typhon rejects this at check time since v0.9.0 — let the binding type drive inference back through the call:
let xs = first[int]([]) # ❌ tyc::operator_type_mismatchlet xs: int? = first([]) # ✅ T inferred as int from the bindingThe first[int](...) shape used to crash at runtime with TypeError: 'function' object is not subscriptable on functions that didn’t carry an __class_getitem__. The v0.9.0 check-time rejection gives you a clean diagnostic instead of a confusing runtime trace.
Spelling list[T] when you only read
If your function only iterates the argument, annotate as Sequence[T] / Iterable[T] — the read-view protocols are covariant on T since v0.9.0, so list[Dog] flows into Sequence[Animal] automatically:
def names(animals: list[Animal]) -> list[str]: # invariant — list[Dog] is rejected return [a.name for a in animals]
def names(animals: Sequence[Animal]) -> list[str]: # ✅ covariant — list[Dog] flows in return [a.name for a in animals]Reserve list[T] / dict[K, V] for functions that mutate.
Single-letter generic names where the meaning is unclear
T is fine for “any type”. For two-parameter generics, prefer K, V over T, U; for “key / value / element” use K, V, T rather than T1, T2, T3.
Mutating arguments
def append(xs: list[int]) -> None: xs.append(42) # ⚠️ caller's list mutatedTyphon doesn’t enforce immutability of arguments. If you need pass-by-value semantics, copy at the boundary or use tuple.
Long parameter lists without keyword-only markers
def connect(host: str, port: int = 5432, ssl: bool = True, timeout: int = 30) -> None: ...Call sites become positional puzzles. Add * for keyword-only:
def connect(host: str, *, port: int = 5432, ssl: bool = True, timeout: int = 30) -> None: ...Now connect("localhost", port=5433) is the only legal call shape — much easier to read.
Returning None to mean “no result”
Result[T, E] with a typed E is more informative than T?. Use T? only when the absence is meaningful and uniform; use Result[T, E] when there’s why it failed.
from typhon_runtime import ... by hand
Don’t. The runtime module is generated. Importing from it manually couples your source to compiler internals.
Where next
- Top Pitfalls — the most common mistakes.
- Performance Gotchas — perf-related.