Generics (PEP 695)
Typhon uses PEP 695 generic syntax exclusively. Type parameters live in square brackets after the function or class name. The vendored Ruff parser accepts the syntax natively, the resolver declares type parameters into scope, and the emitter round-trips them unchanged.
def first[T](xs: list[T]) -> T?: if len(xs) == 0: return None return xs[0]T is a type parameter scoped to this function. Call sites infer it from the arguments.
Generic functions
def map_each[T, U](xs: list[T], f: Callable[[T], U]) -> list[U]: return [f(x) for x in xs]
let lengths: list[int] = map_each(["a", "bb", "ccc"], len) # T = str, U = intMultiple type parameters work as expected.
Generic classes
class Box[T]: value: T
impl[T] Box[T]: def get(self) -> T: return self.value
def map[U](self, f: Callable[[T], U]) -> Box[U]: return Box(value=f(self.value))Notes:
Box[T]in theclassdeclaration introducesT.impl[T] Box[T]:re-declaresTfor use in the methods. The names must match.map[U]introduces a new type parameter local to the method.
Usage:
let b: Box[int] = Box(value=10)let s: Box[str] = b.map(lambda n: f"n={n}") # Box[str]Type aliases
type Vec[T] = list[T]type Pair[A, B] = tuple[A, B]type Lookup[K, V] = dict[K, list[V]]Aliases are transparent — Vec[int] is list[int]. They’re a readability tool, not a new type. The checker substitutes through every alias.
Bidirectional inference
Inference flows in both directions:
Forward — from arguments
def first[T](xs: list[T]) -> T?: ...let n: int? = first([1, 2, 3]) # T = intlet s: str? = first(["a", "b"]) # T = strRecursive — structural
def get[K, V](d: dict[K, V], k: K) -> V?: return d.get(k)
let v: int? = get({"a": 1}, "a") # K = str, V = int (both inferred)Conflict widening
If two parameters force conflicting bindings, the result widens to a union:
def pair[T](a: T, b: T) -> tuple[T, T]: ...
let p = pair(1, "two") # T = int | strThis matches Python’s | semantics. To force a specific binding, annotate the binding (let the result-type drive inference back through the call):
let p: tuple[int, int] = pair(1, 2) # ✅ T = int via expected-type back-propagationExplicit type instantiation is not supported (v0.9.0)
func[T](args) is rejected at check time:
let p = pair[int](1, 2) # ❌ check-time error since v0.9.0let empty = first[int]([]) # ❌ sameerror[tyc::operator_type_mismatch]: type arguments cannot be supplied at the call site; let the expected type drive inference insteadBefore v0.9.0 this crashed at runtime with TypeError: 'function' object is not subscriptable. The compiler now redirects you to bidirectional inference: annotate the binding (forward) or the result (backward), and the call infers T from context. Generic class construction (Box[int](value=7)) is not subject to this rule — the [int] there is part of the class shape, not a function-application syntax.
When inference fails
Empty collections, return-only positions, and ambiguous contexts can prevent inference. Annotate the binding (forward) or pre-build a typed value:
let empty: list[int] = []let first_or_none: int? = first(empty) # T inferred from the list
# Or pre-annotate the result:mut bag: list[int] = []bag.append(0)Bounded type parameters
A bound says “any T that satisfies some interface or class”:
interface Ordered: def __lt__(self, other: Self) -> bool
def smallest[T: Ordered](xs: list[T]) -> T?: if len(xs) == 0: return None mut best: T = xs[0] for x in xs[1:]: if x < best: best = x return bestVariance
Variance describes how a generic type relates to its parameters: Box[Dog] is to Box[Animal] as Dog is to Animal (covariant), opposite (contravariant), or unrelated (invariant).
For your own generic classes, variance is inferred from usage: a plain field is read-write and pins the parameter invariant; a field of a frozen class is read-only and covariant (tuple[T, ...] included); a method parameter is contravariant and a method return covariant — and the methods of every impl[T] Name[T]: block count exactly like methods in the class body. So a frozen Producer[Dog] flows into a Producer[Animal] slot, while a Sink[T] whose impl accepts a T stays invariant. A bare @covariant / @contravariant decorator on the class overrides the inference (both are checker-only and dropped from the emitted Python), and a generic class annotated without arguments (let b: Box = Box(value=1)) accepts any instantiation.
The current checker treats container generics conservatively for the mutable forms, but the read-only protocols are covariant on T (v0.9.0):
list[T]— invariant (becauselistis mutable).tuple[T, ...]— covariant (read-only).Callable[[A], R]— contravariant inA, covariant inR.- User generics (
Box[T]) — invariant by default.
The read-view protocols flow list[Dog] → Sequence[Animal] since v0.9.0:
| Read-only protocol | Variance | Concrete types that flow in |
|---|---|---|
Sequence[T] | covariant on T | list[T], tuple[T, ...] |
Iterable[T] | covariant on T | list[T], tuple[T, ...], set[T], frozenset[T], generators |
Iterator[T] | covariant on T | generators, iter(...) results |
Collection[T] | covariant on T | list[T], tuple[T, ...], set[T], frozenset[T] |
Container[T] | covariant on T | every collection with __contains__ |
Reversible[T] | covariant on T | list[T], tuple[T, ...] |
Mapping[K, V] | K invariant, V covariant | dict[K, V], MappingProxyType[K, V] |
MutableMapping[K, V] | K invariant, V covariant | dict[K, V] |
Use the read-view spelling when a function does not mutate — it accepts more callers without losing precision. See Collections — Read-view covariance for the worked walkthrough.
Full variance annotations (PEP 695’s out T, in T) on user generics are partial; future work will tighten this.
Higher-kinded type parameters (parser scaffold, v0.8.0)
A type parameter can declare itself as a type constructor (a generic that itself takes a type argument) via the [_] marker:
class Functor[F[_]]: pass
def map_through[F[_], A, B](fa: F[A], f: Callable[[A], B]) -> F[B]: ...The parser accepts F[_] as a 1-arg type-constructor parameter; the resolver tracks the kind structure. Full unification of higher-kinded types is not yet enforced — the surface compiles, but the checker won’t catch every kind mismatch. Use HKT signatures conservatively until the frontier work in TYPE_SYSTEM_FRONTIER.md lands.
Type erasure at emit
Typhon erases generics at emit time. The runtime sees plain list, dict, and untyped classes; type safety is a compile-time property only.
def first[T](xs: list[T]) -> T?: ...def first[T](xs: list[T]) -> T | None: ...PEP 695 syntax is preserved because the target is 3.12+. The runtime semantics are identical to plain first(xs) — Python’s runtime doesn’t enforce the constraint.
Common mistakes
Importing TypeVar
from typing import TypeVarT = TypeVar("T")
def first(xs: list[T]) -> T | None: ... # ❌Fix: def first[T](xs: list[T]) -> T?:.
Unconstrained inference
let empty = first([]) # ❌ T unconstrainedFix: annotate the binding so the expected type drives inference back through the call:
let empty: int? = first([]) # T inferred as int from the binding(first[int]([]) is not the fix — explicit type instantiation is rejected at check time since v0.9.0.)
Calling isinstance on a generic type
isinstance(x, Box[int]) # ❌ runtime error — generics eraseFix: check the erased form (isinstance(x, Box)) and re-cast separately.
Where next
- Type Aliases — transparent renames for readability.
- Interfaces (Protocols) — structural typing for bounds.
- Sealed Unions — closed sums with exhaustive matching.