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 T is 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:

  1. Scan all method signatures and attribute types in the class
  2. 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

AspectOld (TypeVar)New ([T])
ScopeModule-levelAnnotation scope
EvaluationEagerLazy (for type aliases)
VarianceExplicitInferred
Forward refsNeed quotes or __future__Work naturally
Multiple classes sharing TSame TypeVar objectDifferent 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 TypeVar for covariant/contravariant parameters if you don’t trust inference
  • Recursive types: Use type statement (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:

  • type statements 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.

pythontypingpython312type-aliases

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.