Python Type Aliases (PEP 695) — Deep Dive
Technical overview
PEP 695 (Python 3.12) added new syntax for type aliases, generic classes, and generic functions. Beyond cleaner syntax, it introduced TypeAliasType, annotation scopes, and automatic variance inference — each with significant implementation details that affect how type checkers and runtime tools interact with your code.
TypeAliasType internals
Object structure
The type statement creates TypeAliasType instances:
type Vector[T] = list[T]
print(type(Vector)) # <class 'typing.TypeAliasType'>
print(Vector.__name__) # 'Vector'
print(Vector.__type_params__) # (T,)
print(Vector.__value__) # list[T] — lazily computed
print(Vector.__module__) # '__main__'
Lazy evaluation implementation
The right-hand side is stored as a code object, not evaluated immediately:
import dis
type Forward = Node | Leaf # Node and Leaf don't exist yet
# The alias stores a closure, not a value
# Accessing __value__ triggers evaluation:
try:
Forward.__value__ # NameError if Node/Leaf undefined
except NameError:
print("Forward references — evaluated on access")
Under the hood, CPython compiles the RHS into a function:
# type Alias = X | Y
# Roughly equivalent to:
# Alias = TypeAliasType("Alias", evaluate=lambda: X | Y)
The lambda captures the enclosing scope (module globals), enabling forward references.
Subscripting
TypeAliasType supports [] to create parameterised aliases:
type Result[T] = tuple[T, str | None]
# Subscripting creates a _GenericAlias
concrete = Result[int]
print(concrete) # tuple[int, str | None]
# Type checkers use this for inference
def process() -> Result[int]:
return (42, None)
Annotation scopes
A new scope type
PEP 695 introduced annotation scopes — a fourth scope type alongside module, class, and function scopes:
Module scope
├── Class scope (Box)
│ ├── Annotation scope ([T]) ← NEW
│ │ └── T is visible here
│ ├── Method scope (push)
│ │ └── T is visible here (inherited from annotation scope)
│ └── Method scope (pop)
│ └── T is visible here
└── T is NOT visible here
Implementation details
Annotation scopes are implemented as implicit nested functions in CPython:
class Stack[T]:
items: list[T]
# CPython generates roughly:
def __type_params__():
T = TypeVar("T")
class Stack:
__type_params__ = (T,)
items: list[T]
return Stack
Stack = __type_params__()
This explains:
- Why
Tis visible inside the class but not outside - Why type parameters can reference each other:
class Pair[T, U: Comparable[T]] - Why comprehension-style scoping works for type params
Interaction with __class__ and super()
Inside a class with type parameters, super() works normally:
class Child[T](Parent[T]):
def method(self) -> T:
super().method() # Works — __class__ cell is set up correctly
return self.value
The annotation scope doesn’t interfere with the class cell mechanism that powers zero-argument super().
Variance inference
How type checkers infer variance
PEP 695 states that variance should be inferred from usage. The algorithm:
- Scan all method signatures and attribute types in the class
- For each type parameter, track where it appears:
- Return types / property getters: covariant position
- Parameter types: contravariant position
- Both: invariant
- Mutable attributes: invariant (both read and write)
class Producer[T]:
def get(self) -> T: ...
# T only in return → covariant
class Consumer[T]:
def accept(self, item: T) -> None: ...
# T only in parameter → contravariant
class Container[T]:
value: T # T in both read and write → invariant
class Processor[T]:
def transform(self, item: T) -> T: ...
# T in both → invariant
Explicit variance
When inference isn’t desired, you can be explicit:
from typing import TypeVar
# Old style — still works
T_co = TypeVar("T_co", covariant=True)
# No new syntax for explicit variance in PEP 695
# You must use TypeVar for explicit variance declarations
Edge cases
class Tricky[T]:
def method(self) -> Callable[[T], None]: ...
# T appears in the parameter of the return type's callable
# This is contravariant in T (nested contravariance flips)
Type checkers handle nested variance flipping correctly.
Comparison: old vs. new style
Behavioural differences
| Aspect | Old (TypeVar) | New ([T]) |
|---|---|---|
| Scope | Module-level | Annotation scope |
| Evaluation | Eager | Lazy (for type aliases) |
| Variance | Explicit | Inferred |
| Forward refs | Need quotes or __future__ | Work naturally |
| Multiple classes sharing T | Same TypeVar object | Different T per class |
| Runtime introspection | __type_params__ not set | __type_params__ available |
The module pollution problem
Old style:
# 10 generic classes = 10+ TypeVars polluting module namespace
T = TypeVar("T")
K = TypeVar("K")
V = TypeVar("V")
T_co = TypeVar("T_co", covariant=True)
T_contra = TypeVar("T_contra", contravariant=True)
S = TypeVar("S", bound=Comparable)
# ... plus the classes themselves
New style:
class Stack[T]: ...
class Mapping[K, V]: ...
class Producer[T]: ... # This T is different from Stack's T
class Consumer[T]: ... # Different T again
class SortedList[S: Comparable]: ...
# No module-level TypeVar clutter
Runtime introspection
__type_params__
Classes and functions expose their type parameters:
class Box[T]:
pass
print(Box.__type_params__) # (T,)
t_param = Box.__type_params__[0]
print(type(t_param)) # <class 'typing.TypeVar'>
print(t_param.__name__) # 'T'
print(t_param.__bound__) # None
print(t_param.__constraints__) # ()
get_type_hints() with new-style generics
from typing import get_type_hints
class Container[T]:
items: list[T]
count: int
hints = get_type_hints(Container)
print(hints) # {'items': list[T], 'count': <class 'int'>}
Migration strategy
Step 1: Identify candidates
# Find all TypeVar declarations
grep -rn "TypeVar(" src/ --include="*.py"
# Find all TypeAlias annotations
grep -rn "TypeAlias" src/ --include="*.py"
Step 2: Convert type aliases
# Before
from typing import TypeAlias, TypeVar
T = TypeVar("T")
Result: TypeAlias = tuple[T, str | None]
# After
type Result[T] = tuple[T, str | None]
Step 3: Convert generic classes
# Before
from typing import Generic, TypeVar
T = TypeVar("T")
class Cache(Generic[T]):
def get(self, key: str) -> T | None: ...
# After
class Cache[T]:
def get(self, key: str) -> T | None: ...
Step 4: Convert generic functions
# Before
from typing import TypeVar
T = TypeVar("T")
def identity(x: T) -> T: return x
# After
def identity[T](x: T) -> T: return x
Step 5: Handle edge cases
- Explicit variance: Keep
TypeVarfor covariant/contravariant parameters if you don’t trust inference - Recursive types: Use
typestatement (lazy evaluation handles recursion) - Shared TypeVars across unrelated classes: With PEP 695, each class gets its own
T— this is usually correct, but verify semantics
Compatibility note
You can mix old and new style in the same codebase. Both mypy and pyright support them interchangeably. The migration can be gradual.
Interaction with from __future__ import annotations
With PEP 695, the need for from __future__ import annotations diminishes:
typestatements are lazy by default — no string quoting needed- Class and function type parameters are scoped properly
- Only standalone annotations (variable annotations in function bodies) still benefit from
__future__annotations
PEP 749 (planned for 3.14) will make all annotations lazy by default, completing the transition.
The one thing to remember: PEP 695 didn’t just add syntax sugar — it fixed fundamental issues with scoping, evaluation order, and namespace pollution that made Python’s old generic system fragile and verbose.
See Also
- Python 310 New Features Python 3.10 gave programmers a shape-sorting machine, friendlier error messages, and cleaner ways to say 'this or that' in type hints.
- Python 311 New Features Python 3.11 made everything faster, error messages smarter, and let you catch several mistakes at once instead of stopping at the first one.
- Python 312 New Features Python 3.12 made type hints shorter, f-strings more powerful, and started preparing Python's engine for a world without the GIL.
- Python 313 New Features Python 3.13 finally lets multiple tasks run at the same time for real, added a speed booster engine, and gave the interactive prompt a colourful makeover.
- Python Exception Groups Python's ExceptionGroup is like getting one report card that lists every mistake at once instead of stopping at the first one.