Blog Post Header Image

> Mastering Python Typing in 2026

A Comprehensive Guide to Modern Type Hints

25 min read
Last updated:

In our projects we strongly rely on typing, which helped us avoid bugs in the earliest stages of development. During adoption of the newest python versions for an enhanced typing experience, current AI models were struggling to keep up with the language changes and the available references were split up over different PEPs, cheat sheets and documentations, making a particular look up tedious. We assembled this document over the last months for internal knowledge sharing and are happy to make it available to the public today.

Tested with Python 3.14.2 and mypy 1.19.1

1. Basic Type Annotations

While Python is a dynamically typed language (decisions about possible method calls, variables and data types are made during runtime), Python allows for optional static type annotations. While these are not enforced at runtime, they can be checked using static type checkers like mypy to provide additional guarantees about the correctness of the code.

def expects_int(val: int) -> int:
    return val + 1

expects_int(2) # OK
expects_int("hello")  # type checker can warn, before any code is run

Python allows functions that accept multiple data types. Using the pipe | symbol between these types defines a Union of types (no need for typing.Union anymore since Python 3.10)

def greet(name: str | None = None) -> str:
    # NOTE: `str | None` replaces the legacy syntax `typing.Optional[str]`
    return f"Hello, {name or 'World'}"

Container types, like list, set, dict and tuple are generics (since Python 3.9), meaning they can be parameterized to specify the type of their contents. Setting the type parameter int | float on list creates a specialized type for our list to indicate, that the list can contain both ints and floats:

l: list[int | float] = [1, 2.0, 5, 3.14]

Note that when using list without a type parameter, it is treated as list[Any], which is not type safe.

The type parameter for tuples is a special case: the type parameter is a sequence of types, one for each element in the tuple. tuple[int, str] expects an element of type int followed by an element of type str:

x: tuple[int, str] = (1, "a")  # OK
y: tuple[int, str] = ("a", 2)  # Fails
z: tuple[int, str] = (1, "a", 3)  # Also Fails

An empty tuple is typed as tuple[()]:

x: tuple[()] = ()

A tuple of variable length can be typed as tuple[int, ...]:

x: tuple[int, ...] = (1, 2)

Classes can be used as types:

class Point:
    def __init__(self, x: float, y: float) -> None:
        # NOTE: While modern mypy natively infers `None` as the return type of `__init__`, as long
        # as at least one argument is typed, explicitly annotating the return type is recommended
        self.x = x
        self.y = y

mypoint: Point = Point(1, 2)

Python 3.12 introduced the type statement for defining type aliases, creating an instance of typing.TypeAliasType:

type Vector = list[float] # the generic `list` is getting bound to `float`, such that `Vector` is a type alias for the specialized `list[float]` type
type Response[T] = T | Exception

Key differences vs assignment aliases:

  • type Alias = ... produces a runtime object of type TypeAliasType.
  • Being of type TypeAliasType implies that it cannot be subclassed, instantiated or used with isinstance()
  • Its value is lazily evaluated, meaning you no longer need quotes for forward references (e.g. previously you had to write Alias: TypeAlias = "SomeClass" in situations where SomeClass is defined later in the file)
  • It can be generic and stores its own type parameters.

2. Type Aliases vs NewType

While type aliases are great for defining semantic short hands, they can be used interchangeably with the right hand side of the expression. This is not true for NewType, which creates a distinct subtype to prevent accidental mixing of types (important for Domain-Driven Design).

from typing import NewType

UserId = NewType('UserId', int)

def get_user(uid: UserId) -> None: ...

get_user(123)  # Mypy error
get_user(UserId(123))  # ok

In runtime, a value of type UserId is just an int and the __add__ method of the underlying int type returns a plain int. As such mypy will catch the following error:

get_user(UserId(123) + UserId(1))  # Mypy error

3. Special Types: Any, object, and Never

All types live somewhere in the type hierarchy. The Top Type (object) sits at the top of the hierarchy, meaning:

  • Values: Every possible value in python is an instance of object (object has uncountable infinite number of values).
  • Methods: Only the absolute minimum number of methods can be guaranteed for an object (e.g. __str__, __eq__, etc.).
# In python, `object` is the safe Top Type - everything is an object, but only methods guaranteed on all objects will pass the type checker
def process_any(data: object) -> None:
    print(str(data)) # Mypy: OK, the existence of `__str__` is guaranteed
    data.non_existent_method()  # Mypy: "object" has no attribute "non_existent_method"

Contrast with Any, which disables type checking (avoid if possible) and subsumes all types (it bypasses the type hierarchy), behaving like both Top and Bottom to allow interaction with untyped code (gradual typing).

from typing import Any

def process_any(data: Any) -> None:
    data.non_existent_method()  # Mypy: OK (but will crash in runtime!)

Middle Types (e.g. float, int, str) sit in the middle of the hierarchy, meaning:

  • Values: The pool of values shrinks (e.g. int only accepts whole numbers)
  • Methods: The set of available methods expands (e.g. int has __add__, __sub__, etc.)
  • Subtypes: bool is a subtype of int - it has fewer possible values (True and False) but possesses all methods of int

The Bottom type is Never - its set has zero possible values (empty set), mathematically it possesses infinite methods (meaning you can call any method on it), but it’s impossible to instantiate.

from typing import Never, assert_never, Literal

# A function that always raises an exception has the return type `Never`:
def always_raise(error: Exception) -> Never:
    raise error

type Status = Literal["ok", "error"]

# `assert_never` can be used to indicate, that a certain code branch should never be reached:
def handle_status(status: Status) -> None:
    match status:
        case "ok": ...
        case "error": ...
        case _ as unreachable:
            assert_never(unreachable)  # Mypy will detect if the type `Status` got a new value and we forgot updating the match statement
            raise ValueError("Invalid status")

4. Type System Utilities: reveal_type, cast, and assert_type

Python’s type system is continuously evolving. Modern libraries like SQLAlchemy make heavy use of generics to provide strong static typing out of the box. reveal_type can help you identify how your type checker reads your code by showing the inferred type in the type checker’s command line output (and at runtime). Consider the following Example:

from typing import reveal_type
from sqlalchemy import select
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column

class Base(DeclarativeBase):
    pass

class Policy(Base):
    __tablename__ = "policy"
    role: Mapped[str] = mapped_column(primary_key=True)
    resource: Mapped[str] = mapped_column(primary_key=True)
    action: Mapped[str] = mapped_column(primary_key=True)

# Assuming `session` is an SQLAlchemy Session:
stmt = select(Policy.action)
reveal_type(stmt)  # Revealed type is "Select[tuple[str]]"

# Executing the statement keeps the types completely intact:
actions = session.scalars(stmt).all()
reveal_type(actions)  # Revealed type is "Sequence[str]"

In practice you will interact with untyped boundaries at some point. Falling back to raw SQL loses type context. Using cast, you can coerce the type checker into understanding a value for another type:

from typing import cast
from sqlalchemy import text

raw_actions = session.scalars(text("SELECT action FROM policy")).all()
reveal_type(raw_actions)  # Revealed type is "Sequence[Any]"

# `cast` tells the type checker to assume this is a list[str] going forward:
typed_actions = cast(list[str], raw_actions)
reveal_type(typed_actions)  # Revealed type is "list[str]"

Ensure during type checking that your types are what you expect with assert_type:

from typing import assert_type

# If the type doesn't match the expected type, mypy raises an error:
assert_type(typed_actions, list[str]) # Gives a type error without the above `cast`

Use cast sparingly and only when you are confident about the runtime type. Note that assert_type evaluates to a no-op at runtime and does not enforce types at runtime. If you want runtime guarantees, isinstance, issubclass, @runtime_checkable, TypeIs and match statements can help. Third party libraries like pydantic, attrs, msgspec and typeguard can also help.

5. PEP 675 - Arbitrary Literal String Type

The LiteralString type can be used, to avoid arbitrary strings to be passed to sensitive functions like SQL queries.

from typing import LiteralString

def execute_query(sql: LiteralString) -> None: ...

safe_query = "SELECT * FROM users" # This passes for a LiteralString
execute_query(safe_query) # OK!

table_name = "user"
execute_query(f"SELECT * FROM {table_name}") # Composing two LiteralStrings is still a LiteralString

user_input = input() # This does not pass for a LiteralString
execute_query(f"SELECT * FROM {user_input}") # Type Error!

6. Liskov Substitution Principle vs The numeric tower

While a strict type system exhibits the broadening of values towards the top of the lattice, narrowing values towards the bottom while the available methods follow the inverse direction, python violates this lattice structure in certain situations.

In strict mathematical terms, the natural numbers and whole numbers (integers) are a subset of the Rational and Real numbers (roughly equivalent to float). In python, however float has some methods that cannot be found on int. This is in violation of the Liskov Substitution Principle (e.g. If Type B (e.g., int) is a subtype of Type A (e.g., float), you must be able to replace A with B without breaking the program. This implies that the subtype must possess all the methods and attributes of the supertype.).

A special rule established by PEP 484 is the int/float/complex relationship, called the “numeric tower”. This rule allows int to be used where float is expected. And float where complex is expected.

The following edge case will therefore pass mypy but crash in runtime:

def print_hex(value: float) -> None:
    # Mypy says: OK! `float` has a `.hex()` method.
    print(value.hex())

# Mypy says: OK! `int` is a subtype of `float` via the Numeric Tower.
print_hex(1)

7. Covariance, Contravariance, Invariance and Protocols

The generic container types list, set and dict are type invariant (tuple however is covariant as it is immutable), meaning the following function will only accept a list of strings:

def greet(names: list[str]) -> None:
    for name in names:
        print("Hi", name)

greet(["Alice", "Bob"]) # OK

This is unnecessarily restrictive when you want to pass a list of a subclass:

from typing import Literal

def known_names() -> list[Literal["Alice", "Bob"]]:
    return ["Alice", "Bob"]

greet(known_names()) # fails, because `Literal["Alice", "Bob"]` is a subtype of `str` and not strictly `str`, but `list` is invariant

If list were covariant (allows subtypes for the type argument), the above code would pass the type checker. However, this would be unsound for mutable containers like list, because it would allow to add objects of the wrong type to the list.

Fortunately, the function greet does not strictly require a list of strings, but only some object which the for loop can interact with on (our function would work for tuples, ranges, dictionaries, sets and even strings, which are iterable over their characters).

In order to loop over something, a python for loop requires the object to be iterable (i.e. implement the __iter__ method). This is what Protocols are for in Python. They allow for structural sub-typing also known as duck typing (the idea being that “if it walks like a duck and quacks like a duck, then it’s a duck”).

Instead of using list, you can use a protocol that is covariant (allows subtypes for the type argument) and iterable (requires the implementation of the __iter__ method to support the for loop syntax). collections.abc provides a number of protocols that you can use in the above example:

  • Iterable (only requires __iter__ method)
  • Iterator (only requires __iter__ and __next__ methods)
  • Generator (expects __iter__, __next__, and send, throw, and close methods)
  • Sequence (expects __len__, __getitem__, __contains__, __iter__, __reversed__, index and count)

By inheriting from typing.Protocol, you can define your own protocols:

from typing import Protocol

class SupportsFormat(Protocol):
    def __format__(self, format_spec: str) -> str: ...

def format_value(value: SupportsFormat) -> str:
    return format(value, "%Y-%m-%d")

Sequence is a good choice for list-like objects (in addition to lists it accepts tuples, ranges, etc. but not sets or dictionaries):

from collections.abc import Sequence
from typing import Literal

def flexible_greet(names: Sequence[str]) -> None:
    for name in names:
        print("Hi", name)

def known_names() -> list[Literal["Alice", "Bob"]]:
    return ["Alice", "Bob"]

flexible_greet(known_names()) # OK

Generally it is a good idea to allow flexible function inputs but return the most specific type possible. Instead of requiring dictionaries, one might accept the broader abc.Mapping protocol as input:

from collections.abc import Mapping

def process_mapping(data: Mapping[str, int]) -> None:
    for key, value in data.items():
        print(f"{key}: {value}")

Function arguments are contravariant, while function return types are covariant. This means a function that accepts a broader type and returns a narrower type can safely replace one that accepts a narrower type and returns a broader type.

from collections.abc import Callable

def process_data(callback: Callable[[int], object]) -> None:
    result = callback(123)
    print(result)


def my_callback(data: float) -> str:
    # input is broader than `int`
    # output is narrower than `object`
    return str(data)

process_data(my_callback)  # OK

In Python 3.12+ (PEP 695), variance is inferred automatically by type checkers:

class Reader[T]:
    def get(self) -> T: ... # T is inferred as covariant

class Writer[T]:
    def put(self, value: T) -> None: ... # T is inferred as contravariant

# Legacy approach (Python 3.11 and older)
from typing import Generic, TypeVar
T_co = TypeVar('T_co', covariant=True)
class LegacyReader(Generic[T_co]): ...

8. PEP 695 - Type Parameter Syntax (Python 3.12+)

The introduction of python’s new type parameter syntax (PEP 695) makes typing.TypeVar and typing.Generic largely obsolete:

# Class with type parameter
class Box[T]:
    def __init__(self, value: T) -> None:
        self.value: T = value

# Function with type parameter
def get_value[T](box: Box[T]) -> T:
    return box.value

# Class with multiple type parameters
class Pair[T, U]:
    def __init__(self, first: T, second: U) -> None:
        self.first: T = first
        self.second: U = second

# Function with multiple type parameters
def swap[T, U](pair: Pair[T, U]) -> Pair[U, T]:
    return Pair(pair.second, pair.first)

Setting an upper bound with S = TypeVar('S', bound=str) now simplifies to:

class StringBox[S: str]:
    def __init__(self, value: S) -> None:
        self.value = value

Setting a constraint with TypeVar('T', bytes, str) becomes:

def char_counter[T: (bytes, str)](value: T) -> int:
    return len(value)

9. PEP 692 - Using TypedDict for more precise **kwargs typing (Python 3.12+)

It is now possible to type **kwargs precisely using TypedDict:

from typing import TypedDict, Unpack

class Movie(TypedDict):
    name: str
    year: int

def make_movie(**kwargs: Unpack[Movie]) -> Movie:
    # NOTE: at runtime, this will be just a standard dict, only the type checker sees it as a `Movie`
    return kwargs

As a dictionary that is assignable to the Movie type is heterogenous, it is not possible to strictly type **kwargs without the Unpack special form. Traditionally one could write **kwargs: int | str, which would imply a dictionary of type dict[str, int | str]. This is less precise than what Unpack + TypedDict allows for.

10. Variadic Generics (PEP 646, Python 3.11+) with PEP 695 (Python 3.12+)

Declare variadic generics in functions for tuple-like arguments. Written as *Ts in the example below, *Ts captures a tuple of an arbitrary number of types.

from collections.abc import Callable
from typing import reveal_type

def move_first_element_to_last[T, *Ts](tup: tuple[T, *Ts]) -> tuple[*Ts, T]:
    return (*tup[1:], tup[0])

y = move_first_element_to_last((1, "a", 3.14))
reveal_type(y)  # tuple[str, float, int]

def call_soon[*Ts](
    cb: Callable[[*Ts], None],
    *args: *Ts,
) -> None:
    cb(*args)

Before variadic generics were introduced, one could use Unpack[Ts] instead of the star operator. While *Ts works for tuple-like arguments, a corollary syntax using a double asterisk for dictionaries (e.g. **T) is not yet supported and still requires Unpack.

Before the type parameter syntax (PEP 695) was introduced, one would have to declare Ts using typing.TypeVarTuple, this is no longer needed.

The concept can be used with classes as well:

class Array[*Shape]:
    def __getitem__(self, key: tuple[*Shape]) -> float: ...
    def __abs__(self) -> Array[*Shape]: ...
    def get_shape(self) -> tuple[*Shape]: ...

11. ParamSpec and Concatenate (PEP 612, Python 3.10+) with PEP 695 (Python 3.12+)

Similarly to the above call_soon example, it is possible to capture the full function signature of a Callable using **P (positional and keyword arguments):

from collections.abc import Callable

# NOTE: Declare with **P, but use without asterisks inside Callable[P, R]
def logged[**P, R](func: Callable[P, R]) -> Callable[P, R]:
    def wrapper(*args: P.args, **kwargs: P.kwargs) -> R:
        print(f"Calling {func.__name__}")
        return func(*args, **kwargs)
    return wrapper

@logged
def add(x: int, y: int) -> int:
    return x + y

Before the type parameter syntax (PEP 695) was introduced, one would have had to declare P using typing.ParamSpec.

Using Concatenate it is possible, to add or remove arguments from the signature of a Callable. This can be used, to inject arguments to a callback:

from collections.abc import Callable
from typing import Concatenate

class DbSession: ...

def inject_session[**P, R](
    func: Callable[Concatenate[DbSession, P], R]
) -> Callable[P, R]:
    def wrapper(*args: P.args, **kwargs: P.kwargs) -> R:
        session = DbSession()
        return func(session, *args, **kwargs)
    return wrapper

@inject_session
def get_user(session: DbSession, user_id: int) -> str:
    return f"User {user_id}"

Note, that some callback types are impossible to express using the current Callable[...] syntax. To express functions with a complex syntax, you can define a Protocol with a __call__ method.

12. PEP 698 - Override Decorator for Static Typing (Python 3.12+)

Explicitly marking overrides prevents accidental signature mismatches and shows intent:

from typing import override

class Parent:
    def bar(self, x: str) -> str:
        return x

class Child(Parent):
    @override
    def baz(self) -> int:    # Type error: Method "baz" is marked as an override,
        # but no base method was found with this name
        return 1

13. Constants and Finality

To indicate, that a variable should not be reassigned or overriden by subclasses, use Final:

from typing import Final

RETRY_COUNT: Final = 5

RETRY_COUNT += 1  # Type error: Cannot assign to final name "RETRY_COUNT"

class Connection:
    TIMEOUT: Final[float] = 0.5

class FastConnection(Connection):
    TIMEOUT = 0.1 # Type error!

The @final decorator can be used to protect methods from being overriden and classes from being subclassed:

from typing import final

class Base:
    @final
    def name(self) -> None: ...

class Derived(Base):
    def name(self) -> None: ... # Cannot override final attribute "name" (previously declared in base class "Base")

14. PEP 673 - Self Type (Python 3.11+)

from typing import reveal_type
# The builder pattern can be used to chain methods and typically returns the instance of the class itself.
class Builder:
    def with_name(self, name: str) -> Builder:
        self.name = name
        return self

# This introduces a problem: `SubBuilder.with_name()` returns `Builder`, not `SubBuilder`.
class SubBuilder(Builder):
    pass

reveal_type(SubBuilder().with_name("test"))  # Revealed type is "Builder"

Using Self type fixes this:

from typing import reveal_type, Self

class Builder:
    def with_name(self, name: str) -> Self:
        self.name = name
        return self


class SubBuilder(Builder):
    pass

reveal_type(SubBuilder().with_name("test"))  # Revealed type is "SubBuilder"

15. PEP 696 - Type Defaults for Type Parameters (Python 3.13+)

Defaults with TypeVar('T', default=int) become:

def get_first[T = int](value: tuple[T]) -> T:
    return value[0]

Available on TypeVar, ParamSpec, and TypeVarTuple.

16. Full set of allowed type parameter declarations

from collections.abc import Callable

# NOTE: this is adapted from the python 3.14.3 reference section and adapted to pass current type checkers
def overly_generic[
    SimpleTypeVar,
    TypeVarWithBound: int,
    TypeVarWithConstraints: (str, bytes),
    TypeVarWithDefault = int,
    *SimpleTypeVarTuple = * tuple[int, float],
    **SimpleParamSpec = [str, bytearray]
](
    a: SimpleTypeVar,
    b: TypeVarWithBound,
    c: TypeVarWithDefault,
    *d: *SimpleTypeVarTuple,
    e: Callable[SimpleParamSpec, TypeVarWithConstraints],
) -> tuple[
    SimpleTypeVar, TypeVarWithBound, tuple[*SimpleTypeVarTuple]
]: raise NotImplementedError

17. PEP 742 - Narrowing types with TypeIs

Python 3.13 added typing.TypeIs, a type predicate return annotation that supports bidirectional narrowing (true narrows to intersection; false excludes the type).

from typing import TypeIs

def is_str(x: object) -> TypeIs[str]:
    return isinstance(x, str)

def f(v: str | int) -> None:
    if is_str(v):
        # v is str here
        ...
    else:
        # v is int here (str excluded)
        ...

In contrast, TypeGuard only narrows unidirectional (on True, while falling back to the wider type on False). TypeIs being more intuitive and restrictive has become the preferred standard.

TIP: TypeIs combines well with NewType.

18. Overloaded Functions

from typing import overload

@overload
def parse(data: str) -> dict[str, int]: ...
@overload
def parse(data: bytes) -> list[str]: ...
def parse(data: str | bytes) -> dict[str, int] | list[str]:
    # NOTE: The implementation signature is ignored by type checkers for external callers.
    # Callers are strictly validated against the @overload signatures above.
    if isinstance(data, str):
        return {"key": 42}
    else:
        return ["a", "b"]

19. Coroutine Typing

import asyncio
from collections.abc import Coroutine
from typing import Any, Literal


# A coroutine is typed like a normal function
async def countdown(tag: str, count: int) -> Literal["Blastoff!"]:
    while count > 0:
        print(f"T-minus {count} ({tag})")
        await asyncio.sleep(0.1)
        count -= 1
    return "Blastoff!"

# The return type is a Coroutine:
awaitable: Coroutine[Any, Any, Literal["Blastoff!"]] = countdown("hello", 10)

20. Generators

from collections.abc import Generator, Iterator

def countdown(n: int) -> Iterator[int]:
    while n > 0:
        yield n
        n -= 1

Iterator[int] is a special case of Generator[YieldType, SendType, ReturnType] and can be used when the generator does not use .send() or .return(), to avoid writing Generator[int, None, None]. While broader (Iterable expects both __iter__ and __next__ method), one could use Iterable[int] here as well (only expects __iter__ method).

Example of a generator that uses .send() and .return():

def echo_round() -> Generator[int, float, str]:
    sent = yield 0
    while sent >= 0:
        sent = yield round(sent)
    return 'Done'

21. PEP 655 - Marking individual TypedDict items as required or potentially-missing (Python 3.11+)

Without additional specifiers, all items in a TypedDict are assumed to be required (implies total=True). Required and NotRequired allow you to specify which items are expected in the dictionary and which ones may be absent.

from typing import NotRequired, TypedDict

class Movie(TypedDict): # `total=True` is implied
    title: str
    year: NotRequired[int]

Likewise:

from typing import Required, TypedDict

class Movie(TypedDict, total=False):
    title: Required[str]
    year: int

Note that this is different from setting an item as Optional (union with None).

22. PEP 705 - TypedDict: Read-only items (Python 3.13+)

Items that are read-only may not be mutated (added, modified, or removed):

from typing import ReadOnly, TypedDict

class Band(TypedDict):
    name: str
    members: ReadOnly[list[str]]

blur: Band = {"name": "blur", "members": []}
blur["name"] = "Blur"  # OK: "name" is not read-only
blur["members"] = ["Damon Albarn"]  # Type check error: "members" is read-only
blur["members"].append("Damon Albarn")  # OK: list is mutable

23. Bridging type annotations with the runtime

In contrast to Typescript, Python’s type annotations are not stripped away during runtime and all type annotations are valid python expressions. This enables library authors, to implement expressive patterns that are not just type-safe, but flexibly define runtime behaviour based on annotations.

Popular libraries like FastAPI, SQLAlchemy and Pydantic, but also our puuid library utilize this to define runtime behaviour:

from typing import Literal
from uuid import UUID

from puuid import PUUIDv4
from pydantic import BaseModel

# puuid introspects the Literal type argument to prefix UUIDs at runtime.
class UserID(PUUIDv4[Literal["user"]]): ...

class User(BaseModel):
    user_id: UserID
    # pydantic validates against the annotation at class instantiation

user_1 = User(user_id=UserID.factory())
print(user_1.model_dump())
# {'user_id': 'user_f8a07cd7-ffda-4f43-99f4-1db93450e4b9'}

# Validation works with strings too
user_2 = User(user_id="user_b100f10f-6876-4b61-984f-2c74be42fcd4")

# the type can even integrate with a json schema, which can be used in FastAPI endpoints
schema = User.model_json_schema()

While Python’s introspection features are out of scope for this document, here are some features from the typing module, that require the language’s ability to introspect:

23.1 PEP 593 - Flexible function and variable annotations (typing.Annotated)

Using Annotated[T, x], it is possible to attach metadata x to a given type T. The type checker just ignores this metadata, but libraries like msgspec utilize this at runtime:

from typing import Annotated
from msgspec import Struct, Meta
import msgspec

UnixName = Annotated[
    str, Meta(min_length=1, max_length=32, pattern="^[a-z_][a-z0-9_-]*$")
]

class User(Struct):
    name: UnixName # validated against the Meta constraints

msgspec.json.decode(b'{"name": "alice"}', type=User) # works in runtime
msgspec.json.decode(b'{"name": "-invalid"}', type=User) # raises a ValidationError

23.2 PEP 544 - Protocols: Structural subtyping (static duck typing), (runtime_checkable)

Using the runtime_checkable decorator, you can use isinstance() and issubclass() to check, if a variable matches a protocol. Note that this can be slower than isinstance() checks on non-protocol classes and hasattr() checks.

from typing import Protocol, runtime_checkable

@runtime_checkable
class Closable(Protocol):
    def close(self) -> None: ...

def close_resource(resource: object) -> None:
    if isinstance(resource, Closable):
        resource.close()

Appendix A. Reference: available features by version and PEP (3.10+)

Python 3.10

  • PEP 604 - Allow writing union types as X | Y
  • PEP 612 - Parameter Specification Variables
  • PEP 613 - Explicit Type Aliases
  • PEP 647 - User-Defined Type Guards

Python 3.11

  • PEP 646 - Variadic Generics
  • PEP 655 - Marking individual TypedDict items as required or potentially-missing
  • PEP 673 - Self Type
  • PEP 675 - Arbitrary Literal String Type
  • PEP 681 - Data Class Transforms

Python 3.12

  • PEP 692 - Using TypedDict for more precise **kwargs typing
  • PEP 695: Type Parameter Syntax
    • generic functions: def f[T](...) -> ...
    • generic classes: class C[T]: ...
    • generic aliases: type Alias[T] = ...
    • bounds/constraints syntax: T: Bound, T: (A, B)
    • type statement
  • PEP 698: Override Decorator for Static Typing - (typing.override)

Python 3.13

  • PEP 696: Type Defaults for Type Parameters - (default=...), plus typing.NoDefault
  • PEP 705: TypedDict: Read-only items - (typing.ReadOnly)
  • PEP 742: Narrowing types with TypeIs - (typing.TypeIs)

Python 3.14

  • PEP 649: Deferred Evaluation Of Annotations Using Descriptors - (from __future__ import annotations no longer needed)
  • PEP 749: Implementing PEP 649

Appendix B. Choosing the right type checker

There are several type checkers available. While mypy serves as the default choice for many, some prefer alternatives for their different philosophy, integration or speed. Pyright and its fork basedpyright are popular alternatives. When considering the right type checker for your project, the python type system conformance report can help making an informed choice. In the current comparison (mypy-1.19.1,pyright-1.1.408, zuban-0.6.2, pyrefly-0.58.0 and ty-0.0.27), the two most compliant ones are listed below (including the unsupported features):

pyright 1.1.408

Unsupported:

  • Type forms/typeforms_typeform

Partial Support:

  • Generics/generics_defaults_specialization - “Allows incorrect assignment to type[].”
  • Overloads/overloads_evaluation - “Does not evaluate Any in some cases where overload is ambiguous.”
  • Tuples/tuples_type_compat - “Incorrectly marks a match case as unreachable.”

zuban 0.6.2

Partial Support:

  • Type annotations/annotations_forward_refs - “Incorrectly generates error for quoted type defined in class scope.”
  • Generics/generics_self_advanced - “Doesn’t allow accessing Self in a classmethod”
  • Type checker directives/directives_type_ignore - “Does not honor # type: ignore comment if comment includes additional text.”

Appendix C. Further Reading

Other

Third Party Utilities

Returns:


Appendix D. Outlook and Current development

Python 3.15

implemented:

  • PEP 747: Annotating Type Forms

accepted:

  • PEP 728: TypedDict with Extra items

considered:

  • PEP 718: Subscriptable functions
  • PEP 746: Type checking Annotated metadata
  • PEP 764: Inline typed dictionaries
  • PEP 767: Annotating Read-Only Attributes
  • PEP 781: Make TYPE_CHECKING a built-in constant
  • PEP 800: Disjoint bases in the type system
  • PEP 821: Support for unpacking TypedDicts in Callable type hints
  • PEP 827: Type Manipulation (Introduces Type booleans, Conditional Types, Unpacked Comprehensions, Type member access, Boolean operators, Basic operators, Union processing, Object inspection, Object creation, InitField, Callable inspection and creation, Generic Callable, Overloaded function types, Raise error, Update class and Lifting)

Developments outside of the accepted roadmap

> Technical Blog

Aktuelle Posts

Alle Posts
Photo of a green python snake staring cautiously at the viewer, illustrating attention towards the Python language.

Mastering Python Typing in 2026

A Comprehensive Guide to Modern Type Hints

Containerschiff, dass farbenfrohe Container über den Ozean transportiert, illustrierend für schnelle und minimale Python Docker Container Images, die uv verwenden.

Mehrstufige Python Docker Images mit UV

Schnellere Builds und kleinere Images dank mehrstufiger Dockerfiles und uv

Scheduling und Verwaltung von Hintergrundjobs in Python mit FastAPI, illustriert durch eine Abflugsanzeige

FastAPI + scheduler = background tasks

Wie man die Scheduler-Library mit FastAPI integriert