Skip to content

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 Optional
def 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 cached
return 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_mismatch
let xs: int? = first([]) # ✅ T inferred as int from the binding

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

Typhon 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