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

PEPYearWhat
6123.10ParamSpec for typed callables
6463.11TypeVarTuple for variadic generics
6733.11Self type
6953.12Native type-parameter syntax
6963.13TypeVar defaults
7053.14ReadOnly TypedDict
7283.14TypedDict 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

SpeedStrengthsUse
mypySlowLargest plugin ecosystem (Django, Pydantic)CI
pyrightFastBest inference; bundled with PylanceEditor
ty (Astral)Very fastNew; will likely match pyrightCI / editor — watch this
Pyrefly (Meta)Very fastInference-focusedWatch

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 .