Skip to content

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 = int

Multiple 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 the class declaration introduces T.
  • impl[T] Box[T]: re-declares T for 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 = int
let s: str? = first(["a", "b"]) # T = str

Recursive — 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 | str

This 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-propagation

Explicit 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.0
let empty = first[int]([]) # ❌ same
error[tyc::operator_type_mismatch]: type arguments cannot be supplied at the call site;
let the expected type drive inference instead

Before 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 best

Variance

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 (because list is mutable).
  • tuple[T, ...] — covariant (read-only).
  • Callable[[A], R] — contravariant in A, covariant in R.
  • User generics (Box[T]) — invariant by default.

The read-view protocols flow list[Dog] → Sequence[Animal] since v0.9.0:

Read-only protocolVarianceConcrete types that flow in
Sequence[T]covariant on Tlist[T], tuple[T, ...]
Iterable[T]covariant on Tlist[T], tuple[T, ...], set[T], frozenset[T], generators
Iterator[T]covariant on Tgenerators, iter(...) results
Collection[T]covariant on Tlist[T], tuple[T, ...], set[T], frozenset[T]
Container[T]covariant on Tevery collection with __contains__
Reversible[T]covariant on Tlist[T], tuple[T, ...]
Mapping[K, V]K invariant, V covariantdict[K, V], MappingProxyType[K, V]
MutableMapping[K, V]K invariant, V covariantdict[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?: ...

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 TypeVar
T = TypeVar("T")
def first(xs: list[T]) -> T | None: ... # ❌

Fix: def first[T](xs: list[T]) -> T?:.

Unconstrained inference

let empty = first([]) # ❌ T unconstrained

Fix: 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 erase

Fix: check the erased form (isinstance(x, Box)) and re-cast separately.

Where next