Python’s type system grew up. Through 2023–2026 we got PEP 695 (cleaner generics), PEP 705 (ReadOnly TypedDicts), better TypedDict semantics, Self, and a much faster checker landscape. This post is the working set of patterns to use in 2026.
What’s new
| PEP | Year | What |
|---|---|---|
| 612 | 3.10 | ParamSpec for typed callables |
| 646 | 3.11 | TypeVarTuple for variadic generics |
| 673 | 3.11 | Self type |
| 695 | 3.12 | Native type-parameter syntax |
| 696 | 3.13 | TypeVar defaults |
| 705 | 3.14 | ReadOnly TypedDict |
| 728 | 3.14 | TypedDict closed/extra |
If you’re on 3.12+ you can use PEP 695. If you’re on 3.13+ you have defaults. The standard library and major frameworks have caught up; type checkers are mature.
PEP 695 — the new generic syntax
Old way (still works):
from typing import TypeVar, Generic
T = TypeVar("T")
def first(xs: list[T]) -> T:
return xs[0]
class Stack(Generic[T]):
def __init__(self) -> None:
self._items: list[T] = []
New way (3.12+):
def first[T](xs: list[T]) -> T:
return xs[0]
class Stack[T]:
def __init__(self) -> None:
self._items: list[T] = []
Cleaner. No TypeVar import. The brackets after the function/class name declare type parameters. It’s the syntax other languages have had for years.
Aliases and bounds
type Vector = list[float] # 3.12 type alias
# With bounds
def sort_items[T: (int, str)](xs: list[T]) -> list[T]: # T must be int or str
return sorted(xs)
def first_with_id[T: HasId](xs: list[T]) -> T: # T must have an id attr
return xs[0]
type Vector = list[float] is lazy-evaluated, which solves the forward-reference issues old aliases had.
TypeVar defaults (3.13+)
class Result[T = str, E = Exception]:
...
Result() # Result[str, Exception]
Result[int]() # Result[int, Exception]
Result[int, ValueError]()
Eliminates a class of “I have to write Result[str] everywhere because I just want the default.”
TypedDict — for dict-shaped data
from typing import TypedDict, NotRequired
class User(TypedDict):
id: int
email: str
full_name: str
is_active: NotRequired[bool]
def process(u: User) -> str:
return u["email"].lower()
TypedDict validates dict shape at static-checker time. At runtime it’s a normal dict. Good for:
- JSON API responses you want typed.
- Config dicts.
- Wire formats where you don’t want a class hierarchy.
In 3.14, NotRequired and Required clarify optional fields, and ReadOnly (PEP 705) marks fields as immutable:
from typing import TypedDict, ReadOnly
class APIResponse(TypedDict):
id: ReadOnly[int] # callers can read but not write
status: str
Protocols — structural typing
from typing import Protocol
class Closeable(Protocol):
def close(self) -> None: ...
def shutdown(c: Closeable) -> None:
c.close()
class FileWrapper:
def close(self) -> None: ...
shutdown(FileWrapper()) # works — structural match, no inheritance
Protocols are duck typing with type checks. Anything with the right shape passes; no isinstance needed. Use when you don’t want to force inheritance — testing, plug-in interfaces, library APIs.
NewType — type-level distinctions
from typing import NewType
UserId = NewType("UserId", int)
OrderId = NewType("OrderId", int)
def get_user(user_id: UserId) -> User: ...
uid = UserId(42)
oid = OrderId(42)
get_user(uid) # ✅
get_user(oid) # ❌ type checker rejects, even though both are ints
Catches the “I passed the wrong ID” bug at compile time. Cheap discipline.
Self — reference the class itself
from typing import Self
class Builder:
def with_name(self, name: str) -> Self:
self.name = name
return self
def with_age(self, age: int) -> Self:
self.age = age
return self
A subclass Builder.with_name returns the subclass type, not the parent. Without Self, you’d write BuilderT = TypeVar("BuilderT", bound="Builder") boilerplate. PEP 673 made this clean.
Literals and discriminators
from typing import Literal, Annotated, TypedDict
from pydantic import Field
class TextEvent(TypedDict):
type: Literal["text"]
content: str
class ClickEvent(TypedDict):
type: Literal["click"]
x: int
y: int
Event = TextEvent | ClickEvent
def handle(e: Event) -> None:
if e["type"] == "text":
print(e["content"]) # narrowed to TextEvent
else:
print(e["x"], e["y"]) # narrowed to ClickEvent
The type checker narrows the union by the discriminator field. In Pydantic v2, Field(discriminator="type") does the same at runtime — see Pydantic v2 Deep Dive
.
ParamSpec — typed decorators
from typing import Callable, ParamSpec, TypeVar
import functools
P = ParamSpec("P")
R = TypeVar("R")
def timed(fn: Callable[P, R]) -> Callable[P, R]:
@functools.wraps(fn)
def wrapper(*args: P.args, **kwargs: P.kwargs) -> R:
...
return fn(*args, **kwargs)
return wrapper
ParamSpec preserves the wrapped function’s signature for the type checker. Without it, every decorator silently widens the signature to Callable[..., Any].
In PEP 695 syntax:
def timed[**P, R](fn: Callable[P, R]) -> Callable[P, R]:
...
For more decorator patterns see Python Decorators Explained .
Async typing
from typing import AsyncIterator, AsyncGenerator
async def fetch_pages() -> AsyncIterator[dict]:
page = 0
while True:
result = await fetch(page)
if not result:
break
yield result
page += 1
async for r in fetch_pages():
process(r)
AsyncIterator[T] for async iteration. AsyncGenerator[YieldT, SendT] if you also send(). See Modern AsyncIO Patterns
.
TypeIs — runtime narrowing
from typing import TypeIs
def is_int_list(x: object) -> TypeIs[list[int]]:
return isinstance(x, list) and all(isinstance(i, int) for i in x)
def process(x: object) -> None:
if is_int_list(x):
sum(x) # narrowed to list[int]
PEP 742 (3.13+). TypeIs[T] narrows the positive path and the negative path correctly. Replaces the older TypeGuard for most use cases.
Frozen dataclasses
from dataclasses import dataclass
@dataclass(frozen=True, slots=True)
class Point:
x: float
y: float
frozen=True makes instances immutable; slots=True reduces memory significantly (~30%). For small immutable value types in hot code paths, this is the right shape.
Style I keep reaching for
Use from __future__ import annotations
from __future__ import annotations
class Tree:
children: list[Tree] # works even before Tree is fully defined
In 3.10+ this is mostly unnecessary; on 3.12+ the new syntax sidesteps it. But for cross-version code, it remains useful.
Prefer X | None over Optional[X]
def find(id: int) -> User | None: ... # 3.10+ syntax, cleaner
Stop importing Optional. The pipe syntax has been stable since 3.10.
Mark deprecated APIs with @deprecated
from typing import deprecated # 3.13+
@deprecated("Use UserService.create_user instead")
def create_user(name: str) -> User:
...
Type checkers warn on usage; IDEs strikethrough; cheap migration aid.
Ban Any in CI
In mypy.ini / pyproject.toml:
[tool.mypy]
strict = true
disallow_any_generics = true
disallow_any_explicit = true
warn_return_any = true
Strict mypy catches the cases where types secretly degraded to Any. Worth fighting through.
Type checkers in 2026
| Speed | Strengths | Use | |
|---|---|---|---|
| mypy | Slow | Largest plugin ecosystem (Django, Pydantic) | CI |
| pyright | Fast | Best inference; bundled with Pylance | Editor |
| ty (Astral) | Very fast | New; will likely match pyright | CI / editor — watch this |
| Pyrefly (Meta) | Very fast | Inference-focused | Watch |
I run pyright in the editor + mypy in CI. The editor needs speed; CI catches what the editor missed.
For the surrounding tooling story see Modern Python Tooling 2026 .
Common mistakes
1. Using dict instead of TypedDict for known shapes
def process(payload: dict) -> None: # ❌ payload['email'] is Any
def process(payload: User) -> None: # ✅
The whole point of types is they propagate. Don’t lose them at the boundary.
2. Any as a band-aid
# type: ignore is sometimes correct. Any rarely is. Reach for object, Mapping[str, Any], or a Protocol instead.
3. Forgetting Awaitable[T] for async return types
def fetch() -> dict: ... # ❌ if it's actually async
async def fetch() -> dict: ... # ✅
The async coloring goes everywhere. Either it’s async or it isn’t.
4. Variant types incorrect
class Animal: ...
class Dog(Animal): ...
def adopt(animals: list[Animal]) -> None: ...
adopt([Dog()]) # ❌ list is invariant
Use Sequence[Animal] (covariant) or Iterable[Animal] for read-only inputs.
5. Mixing Optional[T] with default None
def f(x: int = None): ... # ❌ x is now Optional implicitly
def f(x: int | None = None): ... # ✅
Make optionality explicit.
Read this next
If you want a pyproject.toml with strict mypy, ruff, pyright, and a CI workflow, it’s at rajpoot.dev
.
Building something AI-, backend-, or data-heavy and want a second pair of eyes? I do consulting and freelance work — see my projects and ways to reach me at rajpoot.dev .