Python’s type system has matured significantly in 2026 with Python 3.12+. PEP 695 (type parameter syntax), PEP 696 (TypeVar defaults), and the new type statement make Python generics more readable. This deep dive covers advanced type patterns that experienced Python developers should know.
📋 Table of Contents
New Syntax: type Statement (Python 3.12+)
# Old way (still works)
from typing import TypeAlias, TypeVar
Vector = list[float]
T = TypeVar("T")
# New syntax (Python 3.12+, much cleaner)
type Vector = list[float]
type Matrix = list[Vector]
type JSON = str | int | float | bool | None | list["JSON"] | dict[str, "JSON"]
# Generic type alias
type Stack[T] = list[T]
type Pair[T, U] = tuple[T, U]
type Callback[T] = callable[[T], None]
# Use them
def push[T](stack: Stack[T], item: T) -> Stack[T]:
return [*stack, item]
numbers: Stack[int] = [1, 2, 3]
result = push(numbers, 4)
Generic Classes and Functions (Python 3.12+)
# Old TypeVar syntax (still valid)
from typing import TypeVar
T = TypeVar("T")
def first(items: list[T]) -> T | None:
return items[0] if items else None
# New syntax (PEP 695)
def first[T](items: list[T]) -> T | None:
return items[0] if items else None
def map_list[T, U](items: list[T], fn: callable[[T], U]) -> list[U]:
return [fn(item) for item in items]
class Stack[T]:
def __init__(self) -> None:
self._items: list[T] = []
def push(self, item: T) -> None:
self._items.append(item)
def pop(self) -> T:
if not self._items:
raise IndexError("Stack is empty")
return self._items.pop()
def peek(self) -> T | None:
return self._items[-1] if self._items else None
def __len__(self) -> int:
return len(self._items)
# Usage
stack: Stack[str] = Stack()
stack.push("hello")
stack.push("world")
print(stack.pop()) # "world"
# Constrained TypeVar
def sort_items[T: (int, str, float)](items: list[T]) -> list[T]:
return sorted(items)
TypeVar with Default (PEP 696 — Python 3.13+)
from typing import TypeVar
# TypeVar with default
T = TypeVar("T", default=str)
class Container[T = str]:
def __init__(self, value: T) -> None:
self.value = value
def get(self) -> T:
return self.value
# T defaults to str if not specified
c1: Container = Container("hello") # T = str (default)
c2: Container[int] = Container(42) # T = int (explicit)
Protocols — Structural Subtyping
from typing import Protocol, runtime_checkable
from collections.abc import Sequence
@runtime_checkable
class Sizeable(Protocol):
def __len__(self) -> int: ...
@runtime_checkable
class Comparable[T](Protocol):
def __lt__(self, other: T) -> bool: ...
def __le__(self, other: T) -> bool: ...
def __gt__(self, other: T) -> bool: ...
def __ge__(self, other: T) -> bool: ...
# Any class with __len__ satisfies Sizeable — no inheritance needed
def count_items(collection: Sizeable) -> int:
return len(collection)
print(count_items([1, 2, 3])) # works: list has __len__
print(count_items({"a": 1})) # works: dict has __len__
print(count_items("hello")) # works: str has __len__
# isinstance check (requires @runtime_checkable)
print(isinstance([1, 2], Sizeable)) # True
# Protocol with methods
class Serializable(Protocol):
def to_json(self) -> str: ...
def to_dict(self) -> dict: ...
@classmethod
def from_json(cls, json_str: str) -> "Serializable": ...
Overloads — Multiple Signatures
from typing import overload
@overload
def process(x: int) -> int: ...
@overload
def process(x: str) -> str: ...
@overload
def process(x: list[int]) -> list[int]: ...
def process(x):
if isinstance(x, int):
return x * 2
elif isinstance(x, str):
return x.upper()
elif isinstance(x, list):
return [item * 2 for item in x]
# Type checker knows the return type based on input type
result_int: int = process(5) # type checker: int
result_str: str = process("hi") # type checker: str
result_list: list[int] = process([1, 2, 3]) # type checker: list[int]
TypedDict Advanced Patterns
from typing import TypedDict, Required, NotRequired
# Partial TypedDict — some required, some optional
class UserCreate(TypedDict):
name: Required[str]
email: Required[str]
password: Required[str]
role: NotRequired[str] # optional
# Inheritance
class UserUpdate(TypedDict, total=False):
name: str
email: str
role: str
# Nested TypedDict
class Address(TypedDict):
street: str
city: str
country: str
zip_code: NotRequired[str]
class UserWithAddress(TypedDict):
id: int
name: str
address: Address
# Use in function signatures
def create_user(user: UserCreate) -> int:
return db.insert("users", user)
def update_user(user_id: int, updates: UserUpdate) -> bool:
return db.update("users", user_id, updates)
ParamSpec — Type-Safe Decorators
from typing import ParamSpec, TypeVar, Callable
import functools
P = ParamSpec("P")
R = TypeVar("R")
def retry(max_attempts: int = 3):
def decorator(func: Callable[P, R]) -> Callable[P, R]:
@functools.wraps(func)
def wrapper(*args: P.args, **kwargs: P.kwargs) -> R:
for attempt in range(max_attempts):
try:
return func(*args, **kwargs)
except Exception as e:
if attempt == max_attempts - 1:
raise
raise RuntimeError("unreachable")
return wrapper
return decorator
@retry(max_attempts=3)
def fetch_data(url: str, timeout: int = 30) -> dict:
return requests.get(url, timeout=timeout).json()
# Type checker knows: fetch_data(url: str, timeout: int = 30) -> dict
# The decorator is fully transparent to the type system
Python’s type system in 2026 with the new PEP 695 syntax is significantly more readable than the TypeVar-based syntax. The key patterns: use Protocols for structural typing, ParamSpec for type-safe decorators, TypedDict for typed dictionaries, @overload for multiple signatures, and the new type statement for clean aliases. Run pyright –strict or mypy –strict to enforce these patterns.
📚 You might also like
🔗 Share this article




✍️ Leave a Comment