Python Typing

A complete tour of the typing module, all 105 names.

· Jhonathan Herrera

What this is

typing is the standard library module behind Python's type hints: the names you import to say what a function takes and returns. This is a tour of every one of them rather than a tutorial on annotations, so it assumes you already write them.

Almost nothing here is enforced when your program runs: Final does not prevent reassignment, Literal does not reject a bad value, and a TypedDict is a plain dict the whole time. These names exist to be read by a checker you run yourself, and by frameworks like Pydantic and FastAPI that inspect annotations at import time. Every example below was executed on CPython 3.14, and every checker message came from mypy 2.3.1 or pyright 1.1.411 with the fence naming which. Sections are ordered by subject.

Annotated

Annotated carries a one-line docstring in typing.py: add context-specific metadata to a type. Metadata attached through Annotated is stored on the type itself and can be read back through the special attribute .__metadata__, while the underlying real type is available through .__origin__.

If you go looking for it in the source, note that it is not even the same kind of object between releases: Annotated is a class up to 3.12, and from 3.13 type(typing.Annotated) is _TypedCacheSpecialForm.

To understand the idea of metadata, consider this example using a regular Python class:

class Animal:
    def __init__(self, name, metadata):
        self._name = name
        self.__metadata__ = metadata

animal = Animal(name="Capybara", metadata=("They are big and cute",
                                           "They are very popular in Colombia"))

print(animal._name)
print(animal.__metadata__)
Capybara
('They are big and cute', 'They are very popular in Colombia')

Here __metadata__ is simply an attribute that stores additional context about the object. Nothing special happens unless some code explicitly reads it and uses it.

For Annotated the idea is the same, but it applies to type hints rather than to runtime values. It lets you attach context-specific metadata to a type without changing how that type behaves at runtime. It is written Annotated[<type>, <metadata>], you can supply several metadata values, and the metadata can be any Python object: strings, functions, integers, classes.

from typing import Annotated
from dataclasses import dataclass

Country = Annotated[str, "Must be valid country"]

@dataclass
class LargeAnimals:
    name: str
    country_of_origin: Country

capybara = LargeAnimals(name="capybara", country_of_origin=("Colombia", "Panama"))

print(capybara.name)
print(capybara.country_of_origin)
capybara
('Colombia', 'Panama')

Even though country_of_origin is annotated with metadata, Python treats it exactly like a normal str. Notice that the value passed is a tuple, not a string, and nothing objects. The metadata does not enforce validation or constraints by itself.

So why do we care? In vanilla Python, Annotated makes no difference to runtime behaviour or performance. What it provides is a structured place to attach meaning to a type instead of relying on scattered comments. That becomes useful the moment an external framework reads it, which is exactly what FastAPI and Pydantic do for validation, schema generation and documentation.

Pydantic and Annotated

Knowing Pydantic beforehand helps, but here is the short version. Pydantic is a library for data validation and parsing driven by Python type hints, similar in spirit to @dataclass but with validation built in.

from pydantic import BaseModel

class CharlieBrownCharacters(BaseModel):
    name: str
    age: int

By default Pydantic validates that the provided values match the annotated types. To add constraints to a field, you reach for Field:

from pydantic import BaseModel, Field

class CharlieBrownCharacters(BaseModel):
    name: str = Field(min_length=3)

There is more on Field in Pydantic's documentation.

That works, but from Pydantic v2 onward you can use Annotated to combine the type and its constraints more cleanly:

from typing import Annotated
from pydantic import BaseModel, Field

class CharlieBrownCharacters(BaseModel):
    name: Annotated[str, Field(min_length=3)]
    age: Annotated[int, Field(gt=0, lt=100)]

This behaves exactly like the non-annotated version, but it is:

  • more readable for humans, and
  • easier for static type analyzers to reason about.
snoopy = CharlieBrownCharacters(name="snoopy", age=2)

print(snoopy.name)
print(snoopy.age)
snoopy
2

If a value violates a constraint defined in Field, Pydantic raises a clear error:

lucy = CharlieBrownCharacters(name="lucy", age=200)
line 263, in __init__
    validated_self = self.__pydantic_validator__.validate_python(
        data, self_instance=self)
pydantic_core._pydantic_core.ValidationError: 1 validation error for
CharlieBrownCharacters
age
  Input should be less than 100 [type=less_than, input_value=200,
input_type=int]
    For further information visit
https://errors.pydantic.dev/2.13/v/less_than

FastAPI and Annotated

Just as with Pydantic, when Annotated meets a framework the framework's internal dependency-injection system can interpret the metadata and apply its own logic to it. In practice this lets frameworks treat Annotated metadata as constraints or configuration for incoming values.

In FastAPI you can pair Annotated with Query to define validation rules for query parameters:

from typing import Annotated
from fastapi import FastAPI, Query

app = FastAPI()

@app.get("/items/")
def read_characters(
    name: Annotated[str | None, Query(min_length=3, max_length=50)] = None,
    age: Annotated[int, Query(gt=0, le=100)] = 10,
):
    return {
        "name": name,
        "age": age,
    }

An endpoint is harder to demonstrate than a function, because normally you would need a running server. FastAPI ships TestClient for exactly this, which calls the app in-process:

from fastapi.testclient import TestClient

client = TestClient(app)

print(client.get("/items/?name=snoopy&age=2").json())

refused = client.get("/items/?name=ab")
print(refused.status_code)
print(refused.json()["detail"][0]["msg"])
{'name': 'snoopy', 'age': 2}
422
String should have at least 3 characters

That 422 is the whole point of the section. Nothing in the endpoint body checks the length of name. The only place that rule is written down is inside the Annotated metadata, and FastAPI read it, enforced it, and turned it into an HTTP response before the function ever ran. The same metadata that did nothing at all in the vanilla Python example at the top of this section is now the thing rejecting the request.

Any

Any represents an unconstrained type. It is compatible with every other type, and it can be used in contexts such as Pydantic BaseModel fields.

That said, I would not reach for Any in most cases, since it defeats the purpose of having type checks at all. If you genuinely do not know the type ahead of time, go ahead.

from typing import Any

def favorite_animal(animal_name: Any):
    print(f'I love {animal_name}')

favorite_animal("Capybara")  # I love Capybara
favorite_animal(29)          # I love 29
I love Capybara
I love 29

Callable

Callable is a type annotation that lets you describe a function along with its parameters and its return type. It is how you say that a function is being passed into another function.

from typing import Callable

def sum_dict_value(function: Callable[[dict], int], data: dict) -> int:
    return function(data)

def add_dict(dictionary: dict) -> int:
    return sum(dictionary.values())

values = {"a": 1, "b": 2, "c": 3}
result = sum_dict_value(add_dict, values)

print(result)
6

Why would you want this? Callable earns its keep when you write your own decorator, which lets you modify the behaviour of a function without touching its source. There is more on decorators in the Python Tips chapter, or in any of the video walkthroughs if you prefer a visual explanation.

from typing import Callable, List

def validate_int_list(func: Callable[[List[int]], int]) -> Callable[[List[int]], int]:
    def wrapper(values: List[int]) -> int:
        if not isinstance(values, list):
            raise TypeError("Input must be a list")
        if not all(isinstance(v, int) for v in values):
            raise ValueError("All elements must be integers")
        return func(values)
    return wrapper

@validate_int_list
def sum_of_list(values: List[int]) -> int:
    return sum(values)

print(sum_of_list([1, 2, 3, 4, 5]))
15

ClassVar

ClassVar marks an attribute as belonging to the class itself rather than to each instance, an instance being an individual object created from the class.

To picture it, imagine a Car class without ClassVar. If average is an instance variable, every new Car gets its own copy of it. Create ten cars and you have accidentally created ten separate averages, which is not what anyone wanted.

The goal is for average to represent the average price of every car created, meaning one shared value across all of them rather than one per object.

Be precise about what earns you that, because it is not the annotation. The value lives on the class because it is assigned in the class body, and it would live there just the same with average: float = 0.0 or with no annotation at all. What ClassVar adds is a rule for the checker: nobody may shadow this by assigning it on an instance. Write car.average = 99.0 and mypy reports Cannot assign to class variable "average" via instance, while Python performs the assignment and silently gives that one car a private average, which is the exact bug the annotation exists to catch.

from typing import ClassVar

class Car:
    total: ClassVar[float] = 0.0
    average: ClassVar[float] = 0.0
    amount: ClassVar[int] = 0

    def __init__(self, price: float):
        self._price = price
        Car.total += price
        Car.amount += 1

    @classmethod
    def get_average(cls) -> float:
        cls.average = round(cls.total / cls.amount, 2)
        print(f'The average price of all the cars in the garage is: {cls.average}')

c1 = Car(22000)
c2 = Car(15500.50)
c3 = Car(150000)

Car.get_average()
# The average price of all the cars in the garage is: 62500.17
The average price of all the cars in the garage is: 62500.17

Because total and amount are ClassVars they are shared across every Car instance, which is what allows a @classmethod like get_average to compute across all of them rather than per object.

Final

If you know C++ or C#, you know const makes a variable immutable and unable to be reassigned after initialization. Final is Python's equivalent idea, and it can be used when defining a variable. Java reaches the same place with its own final keyword, which is where Python borrowed the name.

from typing import Final

WHEELS: Final[int] = 4        # A constant: one value, fixed at module level

class Car:
    def __init__(self, wheels_amount: int):
        self.wheels_amount: Final[int] = wheels_amount

bmw = Car(4)
print(bmw.wheels_amount)

bmw.wheels_amount = 6
print(bmw.wheels_amount)
4
6

ForwardRef

A forward reference is a type annotation that refers to a type which has not been declared yet, so it is written as a string.

from typing import ForwardRef

class Node:
    def __init__(self, node: "Node | None", next: "Node | None"):
        self.node = node
        self.next = next

Internally, when you use a string in a type annotation, the typing system and the type checkers treat it as a forward reference and resolve it later. So "Node" is effectively handled as ForwardRef("Node") under the hood.

You should almost never import or use ForwardRef directly unless you are working on internal tooling, libraries or frameworks that manipulate annotations themselves. In normal code, when you need to reference a type that may not exist yet, most commonly in data structures like trees and linked lists, wrap it in quotes.

The other option is the __future__ annotations feature, which removes the quotes entirely:

from __future__ import annotations

class Node:
    def __init__(self, node: Node | None, next: Node | None):
        self.node = node
        self.next = next

Literal

Literal can be thought of as an enum borrowed from another language. Instead of checking a variable's type, it checks the exact value against a list of allowed options. Unlike Python's own Enum, Literal enforces nothing at runtime: errors surface only in a type checker.

Here is why it matters.

class PhoneBatterySetting:
    def __init__(self, mode: str):
        self._battery_mode = mode

    @property
    def battery_mode(self):
        return self._battery_mode

    @battery_mode.setter
    def battery_mode(self, mode: str):
        self._battery_mode = mode
        print(f'Phone battery has been changed to {mode}')

phone1 = PhoneBatterySetting("Default")
phone1.battery_mode = "Charlie Brown"
# Phone battery has been changed to Charlie Brown   (Not a battery mode)
Phone battery has been changed to Charlie Brown

We passed "Charlie Brown" into the setter. Since the setter only expects a str, it passes the type checker and works at runtime, even though it is not a valid battery mode. Instead we can limit the setter so it accepts only specific values, which the checker will then enforce the same way it enforces a type mismatch.

from typing import Literal

BatteryMode = Literal["Default", "Hybrid", "Saving"]

class PhoneBatterySettingV2:
    def __init__(self, mode: str):
        self._battery_mode = mode

    @property
    def battery_mode(self):
        return self._battery_mode

    @battery_mode.setter
    def battery_mode(self, mode: BatteryMode):
        self._battery_mode = mode
        print(f'Phone battery has been changed to {mode}')

phone2 = PhoneBatterySettingV2("Default")
phone2.battery_mode = "Hybrid"
phone2.battery_mode = "Charlie Brown"
error: Incompatible types in assignment (expression has type "Literal['Charlie
Brown']", variable has type "Literal['Default', 'Hybrid', 'Saving']") [assignment]
Found 1 error in 1 file (checked 1 source file)

Optional

Optional indicates that a parameter may be None, meaning the value itself is optional, not that the argument is supplied automatically. It matters where a caller is allowed to omit a value but the code still has to handle that case safely. Optional[Type] is exactly the same as Type | None.

from typing import Optional

class PasswordManager:
    def __init__(self, password: str):
        self._password = password

    def change_password(self, email_address: str, phone_number: int, password: str):
        if email_address:
            self.email(email_address, password)
        else:
            self.phone_number(phone_number, password)

    def email(self, email_address: str, new_password: str):
        print(f"Password changed with email: {email_address}. "
              f"New Password is {new_password}")
        self._password = new_password

    def phone_number(self, number: int, new_password: str):
        print(f"Password changed with number: {number}. "
              f"New Password is {new_password}")
        self._password = new_password

password = PasswordManager("SnoopyIsAmazing")
password.change_password(email_address="snoopy@example.com",
                         password="CharlieBrownIsBetter")
TypeError: PasswordManager.change_password() missing 1 required positional
argument: 'phone_number'

There are two ways to change the password here, by email or by phone number. But choosing one raises a TypeError, because the other parameter is still required, even though logically we only ever want to supply one of the two.

Here is the trap. The obvious move is to reach for Optional, and on its own it changes nothing at all:

class PasswordManager:
    def __init__(self, password: str):
        self._password = password

    def change_password(self, email_address: Optional[str],
                             phone_number: Optional[int],
                             password: str):
        ...

password = PasswordManager("SnoopyIsAmazing")
password.change_password(email_address="snoopy@example.com",
                         password="CharlieBrownIsBetter")
TypeError: PasswordManager.change_password() missing 1 required positional
argument: 'phone_number'

The same error, and the annotation is the reason why not. Optional[int] says the value is allowed to be None. It says nothing about whether the argument is allowed to be left out. Those are two different claims, and only the second one has any bearing on the call above. What makes that call legal is the default:

class PasswordManager:
    def __init__(self, password: str):
        self._password = password

    def change_password(self, email_address: Optional[str] = None,
                             phone_number: Optional[int] = None,
                             password: str = ""):
        if email_address is not None:
            self.email(email_address, password)
        elif phone_number is not None:
            self.phone_number(phone_number, password)
        else:
            raise ValueError("Must provide email or phone number")

    def email(self, email_address: str, new_password: str):
        print(f"Password changed with email: {email_address}. "
              f"New Password is {new_password}")

    def phone_number(self, number: int, new_password: str):
        print(f"Password changed with number: {number}. "
              f"New Password is {new_password}")

password = PasswordManager("SnoopyIsAmazing")
password.change_password(email_address="snoopy@example.com",
                         password="CharlieBrownIsBetter")
Password changed with email: snoopy@example.com. New Password is CharlieBrownIsBetter

Two separate things are doing the work here, and it is worth being precise about which is which. The = None is what makes the argument omittable: delete the annotations entirely and the call still runs. The Optional[str] is what makes the annotation honest, telling the checker that email_address really can be None inside the body, which is why the is not None test that follows is not dead code.

Write the default without Optional and the code runs while the checker believes email_address is always a str. Write Optional without the default, as above, and you get the TypeError again. The pairing is the idiom, and Optional is only ever the half that talks to the checker.

Tuple

Tuple is the construct for describing a fixed-size tuple with a specific order of types. The number of elements and the order you declare them in both matter, and a mismatch is an error to a type checker. Tuples are also immutable at runtime, so their values cannot change after creation.

from typing import Tuple

class User:
    def __init__(self, name: str, age: int):
        self._name: str = name
        self._age: int = age

    def get_info(self) -> Tuple[str, int]:
        return (self._name, self._age)

user1 = User("Adam", 25)
print(user1.get_info())
('Adam', 25)

You can declare as many element types as you like, but the order must match exactly.

example1: Tuple[int, str] = ("Adam", 25)
example2: Tuple[str, int] = ("Adam", 25)   # Correct
error: Incompatible types in assignment (expression has type "tuple[str, int]",
variable has type "tuple[int, str]")  [assignment]

Since tuples are immutable, modifying an element raises at runtime:

example2[1] = 26
example2[1] = 26
~~~~~~~~^^^
TypeError: 'tuple' object does not support item assignment

ParamSpec

When working with callables, especially when writing wrappers or decorators, you accept a function as input and return a new function. The trouble with that pattern is that the wrapper usually receives *args and **kwargs, which makes the type checker lose every piece of information about the original function's parameters.

ParamSpec lets you capture the full parameter signature of a callable and reuse it elsewhere. In practice that means forwarding the exact argument types of one function to another without redefining them by hand.

The pattern shows up in higher-order functions and decorators, wherever preserving the original signature matters for type safety, editor autocomplete and static analysis.

from typing import ParamSpec, Callable, TypeVar, List

P = ParamSpec("P")
R = TypeVar("R")

def log_call(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

@log_call
def sum_of_list(values: List[int]) -> int:
    return sum(values)

@log_call
def greet(name: str, times: int = 1) -> str:
    return ", ".join([name] * times)

print(sum_of_list([1, 2, 3, 4, 5]))
print(greet("Snoopy", times=2))
calling sum_of_list
15
calling greet
Snoopy, Snoopy

Both decorated functions keep their own signatures. The checker still knows greet takes a str and an optional int, even though the call goes through a wrapper that accepts *args and **kwargs:

greet("Snoopy", times="twice")
greet(24)
error: Argument "times" to "greet" has incompatible type "str"; expected "int"
[arg-type]
error: Argument 1 to "greet" has incompatible type "int"; expected "str"  [arg-type]

Now the argument for why ParamSpec is worth the trouble. You could write the decorator without it by spelling the signature out:

def log_call(func: Callable[[List[int]], int]) -> Callable[[List[int]], int]:
    ...

That is perfectly readable, and for sum_of_list it is exactly as precise as the ParamSpec version. The problem is that it is a decorator that now works on one signature and one signature only. Apply it to greet and the checker rejects the decoration itself, because greet is not a Callable[[List[int]], int]. Every new shape of function needs another copy of the decorator.

ParamSpec is what lets one decorator accept any signature and hand the same one back. It is not that spelling out the types is unreadable, it is that there is no set of concrete types you could spell out that covers the next function you decorate.

Protocol

Protocol lets you define a blueprint for behaviour, which is how you get object-oriented polymorphism without inheritance. Instead of requiring a base class, Protocol matches on structure.

You define a protocol by declaring the attributes and functions you expect. Any class that wants to be treated as that type has to follow the same structure, the same attributes and the same function signatures, even though it never inherits from the protocol.

The pattern is common in APIs, libraries and large systems where several classes share an interface but implement it differently: banks, payment processors, phones, universities, anything where objects behave alike and are built differently.

from typing import Protocol

class Bank(Protocol):
    name: str
    balance: int | float

    def withdraw(self, amount: int | float) -> None:
        ...

    def deposit(self, amount: int | float) -> None:
        ...

class WellsFargo:
    def __init__(self, name: str, starting_balance: int | float):
        self.name = name
        self.balance = starting_balance

    def withdraw(self, amount: int | float) -> None:
        self.balance -= amount

    def deposit(self, amount: int | float) -> None:
        self.balance += amount

class Chase:
    def __init__(self, name: str, starting_balance: int | float):
        self.name = name
        self.balance = starting_balance

    def withdraw(self, amount: int | float) -> None:
        self.balance -= amount

    def deposit(self, amount: int | float) -> None:
        self.balance += amount

def check_bank_balance(bank: Bank) -> None:
    print(f"Your balance is {bank.balance} from {bank.name}")

wells = WellsFargo("Wells Fargo", 1000)
chase = Chase("Chase", 500)

check_bank_balance(wells)  # Your balance is 1000 from Wells Fargo
check_bank_balance(chase)  # Your balance is 500 from Chase
Your balance is 1000 from Wells Fargo
Your balance is 500 from Chase

Generic

Generic is a base class that lets you use TypeVar to define placeholder types for class instances. It is useful when a class can work with different types that are only known at the moment it is instantiated.

The types you specify are not enforced at runtime. They exist for static checkers such as mypy or Pyright to catch errors during development.

from typing import Generic, TypeVar

Key = TypeVar("Key")
Value = TypeVar("Value")

class Map(Generic[Key, Value]):
    def __init__(self, key: Key, value: Value):
        self.key = key
        self.value = value

    def __str__(self) -> str:
        return (
            f"The key is {self.key} with type {type(self.key).__name__} "
            f"and the value is {self.value} with type {type(self.value).__name__}"
        )

name_to_age = Map[str, int]("Avery", 24)
print(name_to_age)
# The key is Avery with type str and the value is 24 with type int

home_zip_code_to_owner = Map[int, str](52442, "William")
print(home_zip_code_to_owner)
# The key is 52442 with type int and the value is William with type str

name_to_age.key = 24
The key is Avery with type str and the value is 24 with type int
The key is 52442 with type int and the value is William with type str
error: Incompatible types in assignment (expression has type "int", variable
has type "str")  [assignment]

Type

Type is a type, which is a slightly mind-bending sentence, that represents a class object rather than an instance of one. You reach for it when you want to accept a class and return an instance of it.

from typing import Type

class CharlieBrownCharacter:
    def __init__(self, name: str):
        self._name = name

def make_character(cls: Type[CharlieBrownCharacter], name: str):
    return cls(name)

character1 = make_character(CharlieBrownCharacter, "Snoopy")
print(character1._name)
Snoopy

Type[CharlieBrownCharacter] tells the checker that cls must be a class whose instances are CharlieBrownCharacters, which makes the factory function type safe while staying flexible.

Since Python 3.9, typing.Type is a deprecated alias for the builtin type. It still works, but the recommended form is the lowercase builtin with subscripting, which behaves identically and matches modern syntax:

def make_character_v2(cls: type[CharlieBrownCharacter], name: str):
    return cls(name)

character2 = make_character_v2(CharlieBrownCharacter, "Linus")
print(character2._name)
Linus

TypeVar

TypeVar is close to template <typename T> in C++. You give a function or class a placeholder type, T or whatever letter you like, and the actual type is decided later when the function is called or the object is created. The difference from C++ is that Python does not enforce it at runtime: the relationship is checked statically, and the code runs perfectly well even when the types disagree.

from typing import TypeVar

T = TypeVar('T')

def print_item(item: T) -> T:
    print(f'Item: {item}')
    return item

print_item("hi")  # Item: hi
print_item(67)    # Item: 67
Item: hi
Item: 67

That is the whole idea. TypeVar also takes several extra parameters that let you restrict or control which types are allowed:

TypeVar(
    name,                      # Can be any name
    *constraints,              # Specify exact types
    bound=None,                # Upper type limit (subclasses allowed)
    covariant=False,           # More specific types
    contravariant=False,       # More general types
    infer_variance=False,      # Variance is inferred by type checkers
    default=typing.NoDefault   # Provide default type if not provided
)

These are useful when a type must be a subclass of another type, or must be one of a specific set of types. Below are a few examples adapted from the official typing.TypeVar documentation.

from typing import TypeVar
from collections.abc import Iterable

S = TypeVar('S', bound=Iterable)   # Can be any subtype of Iterable
A = TypeVar('A', int, float)       # Must be an int or a float

def print_iterable(iterable: S) -> S:
    print(f'Print Iterable: {[item for item in iterable]}')
    return iterable

print_iterable([1, 2, 3])  # Print Iterable: [1, 2, 3]
print_iterable((1, 2, 3))  # Print Iterable: [1, 2, 3]

def print_numbers(numbers: A) -> A:
    print(f"Print Number: {numbers}")
    return numbers

print_numbers(4)    # Print Number: 4
print_numbers(4.2)  # Print Number: 4.2
Print Iterable: [1, 2, 3]
Print Iterable: [1, 2, 3]
Print Number: 4
Print Number: 4.2

TypeVarTuple

TypeVarTuple is TypeVar's variadic cousin: instead of standing for one type it stands for several. Think of it as the typing equivalent of *args, a variable-length sequence of types rather than a single placeholder.

Because it represents several types it must always be unpacked with an asterisk, just like *args at runtime. When it is returned from a function it is normally wrapped in a Tuple[...].

A common mistake, and one the Python documentation calls out, is forgetting to unpack it:

from typing import TypeVarTuple

Ts = TypeVarTuple("Ts")

x: Ts          # Not valid
x: tuple[Ts]   # Not valid
x: tuple[*Ts]  # The correct way to do it

The reason is that Ts on its own is not a type. It is a sequence of types, and it has to be unpacked with *.

from typing import TypeVarTuple, Tuple

Ts = TypeVarTuple("Ts")

def s3_bucket(*args: *Ts) -> Tuple[*Ts]:
    for item in args:
        print(f"You want to store: {item}")
    return args

saved_items = s3_bucket("Textbook.pdf", 23000.50, True)
print(saved_items)
You want to store: Textbook.pdf
You want to store: 23000.5
You want to store: True
('Textbook.pdf', 23000.5, True)

The returned Tuple[*Ts] behaves like a normal tuple at runtime, which means it is immutable. Try to modify it and Python raises:

saved_items[2] = False
~~~~~~~~~~^^^
TypeError: 'tuple' object does not support item assignment

TypeVarTuple works in functions, but it is most at home in classes, with the help of Generic:

from typing import TypeVarTuple, Generic, Tuple

As = TypeVarTuple("As")

class Vector(Generic[*As]):
    def __init__(self, *items: *As):
        self.items = items

    def return_items(self) -> Tuple[*As]:
        return self.items

vector1 = Vector("Snoopy", 24, True)

print(vector1.return_items())
# ('Snoopy', 24, True)
print(tuple(type(x) for x in vector1.return_items()))
# (<class 'str'>, <class 'int'>, <class 'bool'>)
('Snoopy', 24, True)
(<class 'str'>, <class 'int'>, <class 'bool'>)

Union

Union gives a variable the ability to be one of several types of your choosing. Set a variable to Union[A, B] and anything you pass that is an A or a B satisfies the checker.

from typing import Union, Literal

class Venmo:
    def __init__(self, amount: Union[int, float]):
        self.total_amount = amount

    def action(
        self,
        amount: Union[int, float],
        action: Literal["Withdraw", "Deposit"],
    ) -> None:
        if isinstance(amount, bool):
            raise TypeError("amount must be int or float, not bool")

        if action == "Withdraw":
            if amount <= self.total_amount:
                self.total_amount -= amount
            else:
                raise ValueError("Insufficient funds")

        elif action == "Deposit":
            self.total_amount += amount

    def print_total_amount(self) -> None:
        print(self.total_amount)

jhons_venmo = Venmo(24.5)
jhons_venmo.print_total_amount()

jhons_venmo.action(30, "Deposit")
jhons_venmo.action(400.23, "Deposit")
jhons_venmo.action(20.22, "Withdraw")

jhons_venmo.print_total_amount()  # 434.51
24.5
434.51

From Python 3.10 onward, Union can be replaced with the cleaner | syntax:

def __init__(self, amount: int | float): ...

Concatenate

Now that ParamSpec and TypeVar are covered, Concatenate is the next step. It lets you add an extra parameter to a callable while preserving the original parameter types captured by ParamSpec, which is what you want when a decorator needs to inject an argument into the function it wraps. What follows is log_call from the ParamSpec section with one change: rather than printing a fixed message it takes the caller's name as a new first argument, which the wrapped function never sees.

Below, a name parameter is injected into the decorated function:

from typing import Concatenate, ParamSpec, Callable, TypeVar, List

P = ParamSpec("P")
R = TypeVar("R")

def log_call_as(func: Callable[P, R]) -> Callable[Concatenate[str, P], R]:
    def wrapper(name: str, *args: P.args, **kwargs: P.kwargs) -> R:
        print(f"{name} is calling this function")
        return func(*args, **kwargs)
    return wrapper

@log_call_as
def sum_of_list(values: List[int]) -> int:
    return sum(values)

print(sum_of_list("Bruno", [1, 2, 3, 4, 5]))
Bruno is calling this function
15

log_call_as returns a Callable[Concatenate[str, P], R]. Callable is the function type itself, str is the new first parameter the decorator injects, and P stands for the remaining parameters of the original function.

Concatenate is hard to hold in your head at first and becomes much clearer once decorators are second nature. Get comfortable with those first, then come back and watch how it preserves and extends a signature.

Self

Self is shorthand for "this method returns an instance of whatever class it was actually called on". Before it existed the workaround was a TypeVar bound to the class, used as the return type, which worked but was verbose and easy to get wrong.

The problem appears the moment you use inheritance. If a parent method is annotated as returning the parent class, calling it on a child object makes the checker forget the object was ever a child, and chaining breaks.

class CharlieBrownCharacter:
    def __init__(self, name: str):
        self._name = name
        self._traits: list[str] = []

    def add_trait(self, trait: str) -> "CharlieBrownCharacter":
        self._traits.append(trait)
        return self

class Beagle(CharlieBrownCharacter):
    def sleep_on_doghouse(self) -> "Beagle":
        return self

snoopy = Beagle("Snoopy")
snoopy.add_trait("Loyal").sleep_on_doghouse()
error: "CharlieBrownCharacter" has no attribute "sleep_on_doghouse"
[attr-defined]

The code runs perfectly at runtime, because add_trait really does return the Beagle. The type checker is the only thing that lost track. Swapping the annotation to Self fixes it:

from typing import Self

class CharlieBrownCharacter:
    def __init__(self, name: str):
        self._name = name
        self._traits: list[str] = []

    def add_trait(self, trait: str) -> Self:
        self._traits.append(trait)
        return self

    def describe(self) -> None:
        print(f"{self._name}: {', '.join(self._traits)}")

class Beagle(CharlieBrownCharacter):
    def sleep_on_doghouse(self) -> Self:
        print(f"{self._name} is asleep on the doghouse")
        return self

snoopy = Beagle("Snoopy")
snoopy.add_trait("Loyal").add_trait("Dramatic").sleep_on_doghouse().describe()

# Snoopy is asleep on the doghouse
# Snoopy: Loyal, Dramatic
Snoopy is asleep on the doghouse
Snoopy: Loyal, Dramatic

Self is most useful for builder patterns, methods that return self so you can chain them, for alternative constructors marked @classmethod, and for __enter__ in context managers. Anywhere the answer to "what type comes back" is "the same one that went in".

Never

Never is the bottom type. It is the type with no values at all, which sounds useless until you realise it is exactly what you want for a function that never finishes normally.

from typing import Never

def crash(message: str) -> Never:
    raise RuntimeError(message)

If you have used NoReturn, this is the same idea. NoReturn and Never are equivalent and type checkers treat them identically, though they are not literally the same object: Never is NoReturn is False. The difference is intent: NoReturn was designed for return annotations, while Never is the general bottom type and reads more naturally anywhere other than a return position.

def unreachable(value: Never) -> None:
    print(f"This can never be called: {value}")

A function taking a Never parameter can never actually be called, because no value has type Never. That sounds like a joke, but it is the entire mechanism behind assert_never, which is how you get a checker to prove you handled every case of a Literal or a union.

Never also turns up in narrowing. Once a checker has eliminated every possible type in a union, the variable is left with type Never, which is the checker telling you that branch is dead code.

NoReturn

NoReturn annotates a function that never returns normally, meaning it always raises or always exits the process.

from typing import NoReturn

def give_up(message: str) -> NoReturn:
    raise RuntimeError(message)

Which is the section above with a different name on it: NoReturn is the older spelling, and the one you will meet in existing codebases.

The benefit either way is narrowing. Once the checker knows a function never returns, it treats everything after a call to it as unreachable, so a helper like give_up works inside an if/else without the checker complaining that some branch is missing a return value.

LiteralString

LiteralString means a string that was written in the source code, not one that came from outside. Any string literal qualifies, and so does any string built by joining, formatting or concatenating other LiteralStrings. What does not qualify is anything that came from input(), a request body, a file or a database.

The whole point is injection safety. This is the type to reach for when a function builds SQL, a shell command or an HTML fragment and you want the checker to guarantee no user data gets into it.

from typing import LiteralString

def run_query(sql: LiteralString) -> None:
    print(f"Running: {sql}")

table = "characters"
run_query("SELECT * FROM " + table)  # Fine, both halves are literals
# Running: SELECT * FROM characters
Running: SELECT * FROM characters

But the moment a real user is involved, the checker steps in:

table: str = input("Table name: ")

run_query("SELECT * FROM " + table)
run_query(table)
error: Argument of type "str" cannot be assigned to parameter "sql" of type
"LiteralString" in function "run_query"

TypeAlias

TypeAlias is an explicit marker saying this assignment is a type alias, not a normal variable. Back in the Literal section we wrote BatteryMode = Literal["Default", "Hybrid", "Saving"]. That line is ambiguous to a checker, because from the outside it looks identical to assigning a value to a variable. TypeAlias removes the ambiguity.

from typing import TypeAlias, Literal

BatteryMode: TypeAlias = Literal["Default", "Hybrid", "Saving"]

It does nothing else. No runtime behaviour, no validation, no performance difference. It exists purely so the checker does not have to guess.

The catch is that TypeAlias was deprecated in Python 3.12 in favour of the new type statement, covered next. It still works and will keep working for a long while, but on 3.12 or newer there is no reason to reach for it in new code.

TypeAliasType

From Python 3.12 you can declare a type alias with the type keyword, and what that statement builds behind the scenes is a TypeAliasType object. Same story as ForwardRef: you will almost never write it by hand, but it helps to know what the type statement is actually producing.

from typing import Literal

type PhoneMode = Literal["Default", "Hybrid", "Saving"]

print(type(PhoneMode).__name__)  # TypeAliasType
print(PhoneMode.__name__)        # PhoneMode
print(PhoneMode.__value__)       # typing.Literal['Default', 'Hybrid', 'Saving']

The interesting part is __value__. A type statement is lazily evaluated, meaning the right-hand side is not resolved until something asks for it. That gives you forward references for free, so you can reference a class before it exists without quoting anything, which is a clean improvement on the ForwardRef workarounds from earlier.

Aliases created this way can also take type parameters, which pairs well with Generic:

type Pair[T] = tuple[T, T]

print(Pair.__type_params__)

coordinates: Pair[int] = (24, 42)
names: Pair[str] = ("Snoopy", "Woodstock")
(T,)

TypeGuard

This one earns its place. Checkers already narrow types for you when you write isinstance(x, str) inside an if. The problem is that the narrowing stops the moment you move that check into its own function, because the checker only sees a function returning bool. It has no idea what that bool means.

def is_str_list(values: list[object]) -> bool:
    return all(isinstance(v, str) for v in values)

def print_names(values: list[object]) -> None:
    if is_str_list(values):
        print(", ".join(values))
error: Argument 1 to "join" of "str" has incompatible type "list[object]";
expected "Iterable[str]"  [arg-type]

TypeGuard is how you tell the checker what the bool means. Instead of returning bool you return TypeGuard[SomeType], which says: if this function returns True, the argument is a SomeType.

from typing import TypeGuard

def is_str_list(values: list[object]) -> TypeGuard[list[str]]:
    return all(isinstance(v, str) for v in values)

def print_names(values: list[object]) -> None:
    if is_str_list(values):
        print(", ".join(values))          # Narrowed to list[str], no error
    else:
        print("Not every item is a string")

print_names(["Snoopy", "Linus", "Lucy"])   # Snoopy, Linus, Lucy
print_names(["Snoopy", 24, True])          # Not every item is a string
Snoopy, Linus, Lucy
Not every item is a string

One more thing worth knowing, and it is the reason the next section exists: TypeGuard only narrows in the True branch. In the else branch the argument keeps its original type.

TypeIs

TypeIs, added in Python 3.13, is the improved version of TypeGuard, and in almost every case it is the one you actually want.

That one-way narrowing is what it fixes. TypeIs narrows in both directions:

from typing import TypeIs

def is_str(value: int | str) -> TypeIs[str]:
    return isinstance(value, str)

def show(value: int | str) -> None:
    if is_str(value):
        print("str branch:", value.upper())  # Narrowed to str
    else:
        print("int branch:", value + 1)      # Narrowed to int

show("snoopy")  # str branch: SNOOPY
show(24)        # int branch: 25
str branch: SNOOPY
int branch: 25

With TypeGuard that else branch would still see int | str and calling value + 1 would be an error. TypeIs behaves the way isinstance() already behaves, which is exactly what you expect.

The trade-off is that TypeIs is stricter about what it lets you write: the narrowed type has to be a subtype of the input type. That is what makes two-way narrowing sound. TypeGuard has no such restriction, so it remains the right choice in the rarer case where you are narrowing to something unrelated, like list[object] to list[str].

Unpack

Back in the TypeVarTuple section we used asterisk syntax to unpack a variadic type variable. Unpack is the spelled-out version of the same operation. These two lines mean exactly the same thing:

from typing import Tuple, TypeVarTuple, Unpack

Ts = TypeVarTuple("Ts")

def s3_bucket(*args: *Ts) -> Tuple[*Ts]: ...               # Asterisk syntax
def s3_bucket(*args: Unpack[Ts]) -> Tuple[Unpack[Ts]]: ...  # Unpack syntax

The asterisk form is cleaner and is what you should use in normal code. Both arrived together in Python 3.11, so Unpack buys you nothing on an older interpreter: on 3.10 the name to import is typing_extensions.Unpack, along with typing_extensions.TypeVarTuple.

Where Unpack really earns its keep is a completely different job: typing **kwargs. Normally the best you can do is **kwargs: str, which says every keyword argument is a string and tells you nothing about which keyword arguments are allowed. Unpack combined with a TypedDict, which gets its own section further down, fixes that.

Note the version here is 3.12, not 3.11. Unpack itself arrived in 3.11 for variadic generics; using it against a TypedDict to describe **kwargs is PEP 692, which landed a release later.

from typing import TypedDict, NotRequired, Unpack

class BucketOptions(TypedDict):
    bucket: str
    public: NotRequired[bool]

def upload(file_name: str, **options: Unpack[BucketOptions]) -> None:
    print(f"Uploading {file_name} with {options}")

upload("Textbook.pdf", bucket="charlie-brown-bucket", public=True)
# Uploading Textbook.pdf with {'bucket': 'charlie-brown-bucket', 'public': True}
Uploading Textbook.pdf with {'bucket': 'charlie-brown-bucket', 'public': True}

Now the checker knows the exact set of valid keyword arguments and the type of each, so typos get caught instead of disappearing silently into the kwargs dict:

upload("Textbook.pdf", bucket="charlie-brown-bucket", region="us-east-1")
error: Unexpected keyword argument "region" for "upload"  [call-arg]

ParamSpecArgs and ParamSpecKwargs

You have already used both of these without knowing it. Go back to the ParamSpec section and look at the wrapper signature: the P.args and P.kwargs in there are ParamSpecArgs and ParamSpecKwargs objects.

from typing import ParamSpec

P = ParamSpec("P")

print(type(P.args).__name__)
print(type(P.kwargs).__name__)
print(P.args.__origin__ is P)
print(P.kwargs.__origin__ is P)
ParamSpecArgs
ParamSpecKwargs
True
True

They exist so that *args and **kwargs in a wrapper can be tied back to one specific ParamSpec. Each holds a reference to its parent in __origin__, which is how a checker confirms that the args and kwargs you are forwarding came from the same signature rather than from two different ones.

You should never need to write these names yourself. Always reach them as P.args and P.kwargs. They are in __all__ mostly for introspection tooling, in the same spirit as ForwardRef.

override

If you have written C++ or Java you already know this one. The @override decorator marks a method as intentionally overriding a parent method. If no such parent method exists, the checker raises an error.

It catches one specific and genuinely annoying bug: a typo in a method name. Without @override, misspelling an override quietly creates a brand new method, the parent version keeps getting called, and nothing complains until you are debugging at 2am wondering why your subclass has no effect.

from typing import override

class Bank:
    def __init__(self, name: str, starting_balance: float):
        self._name = name
        self._balance = starting_balance

    def withdraw(self, amount: float) -> None:
        self._balance -= amount

class WellsFargo(Bank):
    @override
    def withdraw(self, amount: float) -> None:
        print(f"Wells Fargo withdrawal of {amount}")
        self._balance -= amount

wells = WellsFargo("Wells Fargo", 1000)
wells.withdraw(250)
print(wells._balance)
Wells Fargo withdrawal of 250
750

Now the typo version:

class Chase(Bank):
    @override
    def withdrawl(self, amount: float) -> None:   # Note the misspelling
        ...
error: Method "withdrawl" is marked as an override, but no base method was
found with this name  [misc]

The decorator does nothing at runtime beyond setting an __override__ attribute on the function, so as always this only helps if you actually run a checker. Worth noting that @override arrived in 3.12, making it one of the newer additions to the module.

assert_type

assert_type is a test for your type checker rather than for your program. You hand it a value and the type you believe that value has, and the checker errors if it disagrees. At runtime it does nothing at all except return the value you gave it.

from typing import assert_type

def get_name() -> str:
    return "Snoopy"

assert_type(get_name(), str)         # Passes
print(assert_type(get_name(), str))
Snoopy

When the inferred type does not match, you get a clear error:

def get_age() -> int:
    return 8

assert_type(get_age(), str)
error: Expression is of type "int", not "str"  [assert-type]

This is most valuable when you are writing generic code and want to lock the behaviour in. If you have built something with TypeVar or ParamSpec, an assert_type line documents what the inference is supposed to produce and turns any future regression into a build failure rather than a surprise. Library authors use it heavily in their test suites for exactly this reason.

assert_never

This is the exhaustiveness check, and honestly it is one of the highest-value things in the entire module. Remember the BatteryMode literal from earlier. Say you write a function that handles each mode:

from typing import Literal, assert_never

BatteryMode = Literal["Default", "Hybrid", "Saving"]

def apply_battery_mode(mode: BatteryMode) -> None:
    if mode == "Default":
        print("Normal power usage")
    elif mode == "Hybrid":
        print("Balancing performance and battery")
    else:
        assert_never(mode)

We forgot "Saving". Recall from the Never section that no value can ever have type Never, so passing anything to assert_never is an error by construction. Inside that else branch the checker has narrowed mode down to the modes we did not handle, and that leftover type is what gets reported:

error: Argument 1 to "assert_never" has incompatible type "Literal['Saving']";
expected "Never"  [arg-type]

Notice how good that error is. It does not merely say something is wrong, it names the exact case you forgot. Add the missing branch and the error disappears on its own, because now the else really is unreachable.

def apply_battery_mode(mode: BatteryMode) -> None:
    if mode == "Default":
        print("Normal power usage")
    elif mode == "Hybrid":
        print("Balancing performance and battery")
    elif mode == "Saving":
        print("Dimming the screen")
    else:
        assert_never(mode)

apply_battery_mode("Hybrid")  # Balancing performance and battery
Balancing performance and battery

The real payoff comes later. When someone adds a fourth mode to that literal six months from now, every assert_never in the codebase immediately points at the places that need updating. That is a compile-time guarantee you normally only get from languages with real pattern matching.

At runtime assert_never raises an AssertionError if it is ever somehow reached, which is a reasonable fallback for data that came from outside your type system.

reveal_type

reveal_type is a debugging tool. Wrap it around an expression and the checker prints what it thinks that expression's type is, which is invaluable when inference is doing something you did not expect.

character = {"name": "Snoopy", "age": 2}
reveal_type(character)
note: Revealed type is "dict[str, object]"

That output is a good example of why the tool is useful. You might have expected dict[str, str] or something more specific, but because the values mix str and int the checker widened them to object.

There is a wrinkle. For years reveal_type was a magic name that checkers recognised without any import, and calling it crashed at runtime with a NameError. Python 3.11 added a real typing.reveal_type you can import, which prints to stderr and returns its argument:

from typing import reveal_type

character = {"name": "Snoopy", "age": 2}
reveal_type(character)
# Runtime type is 'dict'      (printed to stderr)

Either way, treat it like a print statement you added while debugging. Take it out before you commit.

TypedDict

TypedDict lets you describe the shape of a dictionary: which keys it has and what type each value should be. That is worth more than it sounds, because dictionaries are everywhere in Python, in JSON responses, config files and API payloads, and dict[str, Any] tells you almost nothing.

The important thing to understand is that a TypedDict is still a plain dict at runtime. No class, no validation, no overhead. All the checking happens statically.

from typing import TypedDict

class CharacterInfo(TypedDict):
    name: str
    age: int

charlie: CharacterInfo = {"name": "Charlie Brown", "age": 8}

print(type(charlie))
print(charlie["name"])
<class 'dict'>
Charlie Brown

A checker will catch a missing key, an unknown key, and a wrong value type:

sally: CharacterInfo = {"name": "Sally"}
lucy: CharacterInfo = {"name": "Lucy", "age": 8, "pet_name": "None"}
linus: CharacterInfo = {"name": "Linus", "age": "eight"}
error: Missing key "age" for TypedDict "CharacterInfo"  [typeddict-item]
error: Extra key "pet_name" for TypedDict "CharacterInfo"
[typeddict-unknown-key]
error: Incompatible types (expression has type "str", TypedDict item "age" has
type "int")  [typeddict-item]

There is also a functional syntax, which you need when a key is not a valid Python identifier, anything with a dash or a space in it, which happens constantly with real JSON:

CharacterInfo = TypedDict("CharacterInfo", {"name": str, "pet-name": str})

By default every key is required. The next two sections, Required and NotRequired, are how you change that per key.

Required and NotRequired

NotRequired marks an individual key as optional without touching the rest of the TypedDict.

from typing import TypedDict, NotRequired

class CharacterInfo(TypedDict):
    name: str
    age: int
    pet_name: NotRequired[str]

charlie: CharacterInfo = {"name": "Charlie Brown", "age": 8, "pet_name": "Snoopy"}
linus: CharacterInfo = {"name": "Linus", "age": 8}  # Fine, pet_name is optional

print(sorted(CharacterInfo.__required_keys__))
print(sorted(CharacterInfo.__optional_keys__))
['age', 'name']
['pet_name']

The two key sets are frozensets. They are sorted on the way out here because a frozenset's repr order depends on hash randomisation, so printing one directly gives an output that changes between runs.

Required is the mirror image. If you flip the whole TypedDict to optional with total=False, Required lets you pull individual keys back to being mandatory.

from typing import TypedDict, Required

class LooseCharacter(TypedDict, total=False):
    name: Required[str]
    age: int

print(sorted(LooseCharacter.__required_keys__))
['name']

You could technically express the same thing by splitting the TypedDict into two classes and inheriting one from the other, but Required and NotRequired keep it in one place, which is much easier to read.

ReadOnly

ReadOnly, added in Python 3.13, is the third TypedDict modifier. It marks a key as not reassignable after the dictionary is built.

from typing import TypedDict, ReadOnly

class Config(TypedDict):
    name: ReadOnly[str]
    retries: int

config: Config = {"name": "charlie-brown-bucket", "retries": 3}

config["retries"] = 5   # Fine
config["name"] = "other"

print(sorted(Config.__readonly_keys__))
['name']
error: ReadOnly TypedDict key "name" TypedDict is mutated
[typeddict-readonly-mutated]

Think of it as Final, but for a dictionary key instead of a variable. And exactly like Final, nothing stops you at runtime. The assignment above will happily succeed if you run the file; only the checker complains.

is_typeddict

One more for the TypedDict family, and a small one: is_typeddict tells you whether an object is a TypedDict class.

from typing import TypedDict, is_typeddict

class CharacterInfo(TypedDict):
    name: str
    age: int

print(is_typeddict(CharacterInfo))
print(is_typeddict(dict))
True
False

The reason it needs to exist is the thing the last four sections have been circling: a TypedDict is not really a class, it is a plain dict at runtime with a class-shaped declaration. isinstance() does not work with one at all, so there is no obvious way to detect one. This is the supported way.

Like get_type_hints, get_origin and get_args, this is an introspection helper aimed at frameworks rather than application code. If you are building something that walks over annotations, Pydantic-style, you will need it. Otherwise you probably will not.

NamedTuple

NamedTuple is the tuple equivalent of TypedDict: a tuple where each position also has a name and a type. If you have used collections.namedtuple, this is the same thing with type annotations and a much nicer class-based syntax.

from typing import NamedTuple

class CharacterTuple(NamedTuple):
    name: str
    age: int = 8         # Defaults are allowed

snoopy = CharacterTuple("Snoopy", 2)

print(snoopy)
print(snoopy.name)
print(snoopy[1])
print(CharacterTuple("Linus"))
# CharacterTuple(name='Linus', age=8)
print(snoopy._asdict())
CharacterTuple(name='Snoopy', age=2)
Snoopy
2
CharacterTuple(name='Linus', age=8)
{'name': 'Snoopy', 'age': 2}

Unlike TypedDict, a NamedTuple is a real class at runtime and it really does subclass tuple, so you get indexing, unpacking, iteration and immutability for free.

So when do you use this instead of a @dataclass? Use NamedTuple when the thing genuinely is a fixed sequence you want to unpack or index, and when immutability is a feature rather than an annoyance. Use a dataclass when you want mutability, inheritance, or more than a handful of fields. Coordinates and database rows are good NamedTuples. A user account is a better dataclass.

NewType

NewType creates a type that is distinct to the checker but the exact same type at runtime. It sounds pointless until you have been bitten by the problem it solves.

Say you have a function that takes a character ID and another that takes an age. Both are ints, so nothing stops you swapping the arguments by accident, and the checker will never say a word about it.

from typing import NewType

CharacterId = NewType("CharacterId", int)

def lookup(cid: CharacterId) -> str:
    return f"Looking up character {cid}"

print(lookup(CharacterId(24)))

lookup(24)
Looking up character 24
error: Argument 1 to "lookup" has incompatible type "int"; expected
"CharacterId"  [arg-type]

Now the only way to get a CharacterId is to construct one explicitly, which forces you to think about it at the boundary where the value enters your system. Everything after that is checked for you.

At runtime it costs essentially nothing. CharacterId(24) just returns 24, and it is a plain int:

print(CharacterId(24) + 1)
print(type(CharacterId(24)).__name__)
25
int

This is a great fit for anything that is technically a str or int but semantically a specific kind of one: user IDs, API keys, URLs, file paths, currency amounts. If you have ever mixed up two IDs in a function call, this is your fix.

overload

@overload lets you declare several different signatures for a single function. You write the stub signatures with empty bodies, then one real implementation underneath that handles all of them.

The reason you would want this is precision. A union return type like int | str forces every caller to handle both cases, even when the answer is fully determined by what they passed in. Overloads let the checker pick the right return type per call.

from typing import overload

@overload
def double(value: int) -> int: ...
@overload
def double(value: str) -> str: ...
def double(value: int | str) -> int | str:
    return value * 2

print(double(24))
print(double("Snoopy"))
48
SnoopySnoopy

Now double(24) is known to return an int and double("Snoopy") is known to return a str, rather than both returning int | str.

Two rules trip people up. First, the overload stubs are erased at runtime, so only the final implementation actually exists as a function. Second, the implementation signature is never visible to callers, which means it has to be broad enough to accept every overload, but nobody can call it directly with that broad signature.

get_overloads and clear_overloads

"Erased at runtime" used to be literally true, which meant introspection tools had no way to see the overloads at all. get_overloads, added in 3.11, gives them back to you.

from typing import overload, get_overloads

@overload
def describe(value: int) -> str: ...
@overload
def describe(value: str) -> str: ...
def describe(value: int | str) -> str:
    return f"{value!r} is a {type(value).__name__}"

print(len(get_overloads(describe)))

for f in get_overloads(describe):
    print(f.__annotations__)

# {'value': <class 'int'>, 'return': <class 'str'>}
# {'value': <class 'str'>, 'return': <class 'str'>}
2
{'value': <class 'int'>, 'return': <class 'str'>}
{'value': <class 'str'>, 'return': <class 'str'>}

clear_overloads is the cleanup half. The overload registry is a module-level cache holding a reference to every overloaded function ever defined, and clear_overloads wipes it.

from typing import clear_overloads

clear_overloads()
print(get_overloads(describe))
[]

cast

cast is you telling the checker "trust me, I know what this is". It takes a type and a value, and returns the value completely unchanged. No conversion, no validation, no runtime check of any kind.

from typing import cast

values: list[object] = ["Snoopy", "Linus"]

print(", ".join(values))

names = cast(list[str], values)
print(", ".join(names))
Snoopy, Linus
Snoopy, Linus
error: Argument 1 to "join" of "str" has incompatible type "list[object]";
expected "Iterable[str]"  [arg-type]

Prefer TypeGuard or TypeIs when you can, since those at least run a real check. Reach for cast only when you genuinely know something the checker cannot: deserialized JSON you have already validated, a third-party library with incomplete stubs, or a value you just narrowed in a way the checker could not follow.

final

Not to be confused with Final, covered earlier. Final with a capital F is for variables. @final in lowercase is a decorator for classes and methods, and it means this cannot be subclassed or overridden. It is the equivalent of the final keyword in Java or a sealed class in C#.

from typing import final

@final
class Beagle:
    def bark(self) -> None:
        print("Woof")

class Snoopy(Beagle):
    ...
error: Cannot inherit from final class "Beagle"  [misc]

You can also apply it to individual methods, which allows subclassing the class but forbids overriding that particular method:

class Bank:
    @final
    def audit_log(self) -> None:
        print("Do not override this")

class WellsFargo(Bank):
    def audit_log(self) -> None:
        ...
error: Cannot override final attribute "audit_log" (previously declared in
base class "Bank")  [misc]

Same story as everything else: no runtime enforcement. The decorator sets a __final__ attribute and that is it. Python will let you subclass a @final class all day long.

It pairs nicely with @override from earlier. @override says "I intend to override this", @final says "you may not".

runtime_checkable

Back in the Protocol section, our Bank protocol worked great for static checking. But try using it with isinstance() and Python refuses.

from typing import Protocol

class Bank(Protocol):
    def withdraw(self, amount: float) -> None: ...

class WellsFargo:
    def withdraw(self, amount: float) -> None: ...

isinstance(WellsFargo(), Bank)
TypeError: Instance and class checks can only be used with @runtime_checkable
protocols

@runtime_checkable unlocks it. Add the decorator and isinstance() starts working, checking structurally by looking for the required attribute names.

from typing import Protocol, runtime_checkable

@runtime_checkable
class Bank(Protocol):
    def withdraw(self, amount: float) -> None: ...

class WellsFargo:
    def withdraw(self, amount: float) -> None: ...

class Toaster:
    def toast(self) -> None: ...

print(isinstance(WellsFargo(), Bank))
print(isinstance(Toaster(), Bank))
True
False

It is also noticeably slower than a normal isinstance() call, since it has to walk the attribute list every time. Fine for setup code, worth avoiding in a hot loop.

get_protocol_members and is_protocol

These two arrived in Python 3.13 and are introspection helpers for protocols, in the same family as is_typeddict.

from typing import Protocol, get_protocol_members, is_protocol

class Bank(Protocol):
    _name: str
    def withdraw(self, amount: float) -> None: ...

print(sorted(get_protocol_members(Bank)))
print(is_protocol(Bank))
print(is_protocol(int))
['_name', 'withdraw']
True
False

Before these existed you had to poke at the private __protocol_attrs__ attribute, which is exactly the kind of thing that breaks between Python versions. These are the supported way to ask the same questions.

Two answers from them are worth knowing before you rely on either. is_protocol is asking whether the class is itself a protocol, not whether it satisfies one, and inheriting from a protocol does not make you one: class Impl(Bank): ... gives is_protocol(Impl) == False, and so does is_protocol(Protocol) itself. And get_protocol_members counts a single leading underscore as a member while skipping __init__, so _name above is part of the contract. It returns a frozenset, and raises TypeError rather than returning empty if you hand it something that is not a protocol at all.

TYPE_CHECKING

TYPE_CHECKING is a constant that is False at runtime and True while a checker is analyzing your code. That sounds like a strange thing to want until you hit circular imports.

Say module A needs a type from module B for annotations only, and module B already imports module A. Importing normally gives you an ImportError. Putting the import inside an if TYPE_CHECKING block means the checker sees it but Python never executes it.

from __future__ import annotations
from typing import TYPE_CHECKING

if TYPE_CHECKING:
    from banks import WellsFargo  # Never imported at runtime

def audit(bank: WellsFargo) -> None:
    print(bank._name)

print(TYPE_CHECKING)
False

The from __future__ import annotations line matters on 3.13 and earlier. Without it the annotation is evaluated at definition time, WellsFargo does not exist, and you get a NameError. With it, or by quoting the annotation as in the ForwardRef section, annotations are stored as strings and never evaluated unless something explicitly asks for them.

On 3.14 the import is redundant, because PEP 649 already defers the evaluation: drop the line and the function still defines cleanly. What does not change is what happens when something does ask. Call get_type_hints(audit) in either world and the name still has to resolve, so a TYPE_CHECKING import is still an annotation you are promising nobody will try to read at runtime.

The other common use is import cost. If a type appears only in annotations and comes from a heavy library, moving it into a TYPE_CHECKING block keeps it out of your import path entirely.

get_type_hints

get_type_hints is how you read annotations properly at runtime. You might reasonably ask why not just read __annotations__ directly, and the answer is that __annotations__ often contains strings rather than actual types, thanks to forward references and from __future__ import annotations. get_type_hints resolves all of them for you.

from typing import get_type_hints, Optional

def describe(name: str, age: Optional[int] = None) -> str:
    ...

print(get_type_hints(describe))
# {'name': <class 'str'>, 'age': int | None, 'return': <class 'str'>}
{'name': <class 'str'>, 'age': int | None, 'return': <class 'str'>}

That int | None is a 3.14 answer. Up to 3.13 the same line prints typing.Optional[int], because 3.14 is where typing.Union and the | operator finally became the same object. The type did not change, only how it prints.

One behaviour worth knowing, and it connects directly back to the Annotated section at the very start: by default get_type_hints strips Annotated metadata off. You have to ask for it explicitly.

from typing import Annotated, get_type_hints

Country = Annotated[str, "Must be valid country"]

def describe(origin: Country) -> None: ...

print(get_type_hints(describe))
# {'origin': <class 'str'>, 'return': <class 'NoneType'>}

print(get_type_hints(describe, include_extras=True))
# {'origin': typing.Annotated[str, 'Must be valid country'],
#  'return': <class 'NoneType'>}
{'origin': <class 'str'>, 'return': <class 'NoneType'>}
{'origin': typing.Annotated[str, 'Must be valid country'], 'return': <class 'NoneType'>}

That include_extras=True flag is the exact line of code that makes Pydantic and FastAPI work. When we said at the beginning that Annotated metadata does nothing until a framework reads it, this is the reading.

get_origin and get_args

These two take apart a type annotation. get_origin gives you the outer container, get_args gives you what is inside the brackets.

from typing import get_origin, get_args, Optional

print(get_origin(list[int]))
print(get_args(list[int]))

print(get_origin(dict[str, int]))
print(get_args(dict[str, int]))

print(get_origin(Optional[int]))
print(get_args(Optional[int]))
<class 'list'>
(<class 'int'>,)
<class 'dict'>
(<class 'str'>, <class 'int'>)
<class 'typing.Union'>
(<class 'int'>, <class 'NoneType'>)

That last one is a nice reminder that Optional[int] is just Union[int, None] under the hood, exactly as the Optional section said.

For plain types that are not subscripted, get_origin returns None, which is the usual way to test whether you are looking at a generic:

print(get_origin(int))
print(get_args(int))
None
()

Combined with get_type_hints, these are the whole toolkit for walking over a set of annotations and doing something with them. Every validation library you have used is built on some version of this loop.

evaluate_forward_ref

New in Python 3.14. This is the explicit, supported way to resolve a ForwardRef into the real type it points at.

Back in the ForwardRef section we said you should almost never touch ForwardRef directly. That is still true. But if you are the one writing the framework, you eventually need to turn that string into an actual type, and until 3.14 the only way was to call the private ForwardRef._evaluate method and hope it did not change between releases.

from typing import ForwardRef, evaluate_forward_ref

class Node:
    ...

ref = ForwardRef("Node")
print(evaluate_forward_ref(ref, globals=globals()))
# <class '__main__.Node'>
<class '__main__.Node'>

It accepts owner, globals, locals and type_params arguments to tell it where to look up the name, which is the fiddly part that made the private method so easy to get wrong.

The reason to know the name at all is defensive. ForwardRef._evaluate really did change underneath its callers: on 3.11 it is _evaluate(globalns, localns, recursive_guard), and on 3.12 it is _evaluate(globalns, localns, type_params=None, *, recursive_guard), so anything passing that third argument positionally broke on upgrade. That is the whole argument for a public function.

no_type_check and no_type_check_decorator

@no_type_check is the escape hatch for an entire function or class. Apply it and the checker ignores every annotation inside.

from typing import no_type_check

@no_type_check
def messy_legacy_function(value: int) -> str:
    return value + "this would normally be an error"

The honest advice is to avoid this. It is much blunter than cast, which at least targets one specific expression, and blunter than a # type: ignore comment, which targets one line. Turning off checking for a whole function tends to hide more than the one thing you were trying to hide.

no_type_check_decorator is the stranger of the two. It was meant to let you wrap your own decorator so it applied no_type_check behaviour automatically. In practice no checker ever implemented it, it does nothing useful, and it was deprecated in Python 3.13 and scheduled for removal. Do not use it. It is mentioned here only because it really is in __all__ and this document is supposed to be complete.

AnyStr

AnyStr is a constrained TypeVar that was defined roughly like this:

from typing import TypeVar

AnyStr = TypeVar("AnyStr", str, bytes)

The point was to write functions that work with either str or bytes while keeping them consistent, so passing str gives you str back and passing bytes gives you bytes back. Just using str | bytes would let you mix the two, which is exactly the bug you are trying to avoid.

from typing import AnyStr

def concat(a: AnyStr, b: AnyStr) -> AnyStr:
    return a + b

print(concat("Snoopy", " Brown"))    # Snoopy Brown
print(concat(b"Snoopy", b" Brown"))  # b'Snoopy Brown'

concat("Snoopy", b" Brown")
error: Value of type variable "AnyStr" of "concat" cannot be "Sequence[object]"
[type-var]

AnyStr was deprecated in Python 3.13 and is scheduled for removal in 3.18. The replacement is the 3.12 generic syntax, which does the same thing more clearly and without a magic name:

def concat[T: (str, bytes)](a: T, b: T) -> T:
    return a + b

Text

Text is an alias for str. That is the entire feature.

from typing import Text

def greet(name: Text) -> Text:
    return f"Hello {name}"

print(Text is str)
True

It exists for a historical reason that no longer applies. In Python 2, str meant bytes and unicode meant text, so code that had to run on both 2 and 3 used Text to say "I mean actual text here". Python 2 has been dead since 2020.

It is deprecated and there is no reason to use it in new code. Just write str.

NoDefault

NoDefault, added in Python 3.13, is a sentinel value. Python 3.13 gave TypeVar the ability to have a default type, and that immediately created a problem: how do you tell the difference between a TypeVar whose default is None and one that has no default at all? None cannot mean both things. NoDefault is the answer.

from typing import TypeVar, NoDefault

T = TypeVar("T")
D = TypeVar("D", default=int)

print(T.has_default())
print(T.__default__ is NoDefault)

print(D.has_default())
print(D.__default__)
False
True
True
<class 'int'>

In practice you should call has_default() rather than comparing against NoDefault yourself. The sentinel is mostly there so that __default__ always has something meaningful in it.

You will see this same pattern all over the standard library under different names, since it is the standard trick for "no value was provided" when None is itself a legal value.

The Supports protocols

typing ships seven small ready-made protocols, all following the same pattern: each describes a single dunder method. They are SupportsAbs, SupportsBytes, SupportsComplex, SupportsFloat, SupportsIndex, SupportsInt and SupportsRound.

Each corresponds to exactly what you would guess: SupportsInt means the object has an __int__ method, SupportsAbs means it has __abs__, and so on. They are all runtime checkable, so isinstance() works out of the box.

from typing import SupportsIndex, SupportsFloat, SupportsAbs

def repeat(text: str, times: SupportsIndex) -> str:
    return text * times.__index__()

print(repeat("Snoopy ", 3))

print(isinstance(24, SupportsIndex))
print(isinstance(2.5, SupportsFloat))
print(isinstance("Snoopy", SupportsAbs))
Snoopy Snoopy Snoopy 
True
True
False

The one you will actually encounter is SupportsIndex, because it is what the standard library uses for anything that can be a sequence index. Annotating an index parameter as SupportsIndex instead of int correctly accepts numpy integers, enum members and anything else implementing __index__, which int alone would reject.

They are also worth reading as examples. They are the simplest possible protocols, and looking at how they are written makes the Protocol section click faster than any explanation does.

IO, TextIO and BinaryIO

These three annotate file-like objects. IO is the generic form, TextIO is IO[str], and BinaryIO is IO[bytes].

from typing import TextIO, BinaryIO
import io

def write_report(stream: TextIO, text: str) -> None:
    stream.write(text)

buffer = io.StringIO()
write_report(buffer, "Snoopy")
print(buffer.getvalue())

with open("report.txt", "w") as f:
    write_report(f, "Charlie Brown")
Snoopy

The nice thing about typing the parameter rather than hardcoding a filename is that io.StringIO satisfies TextIO just as well as a real file does, which makes the function trivially testable without touching the disk.

That said, the modern recommendation is to use protocols in typing.IO's place where you can. If your function only calls .write(), annotating it as SupportsWrite[str] from _typeshed, or just defining your own one-method Protocol, is more precise and more flexible than demanding a full TextIO. These three are not deprecated, but they ask for more than most functions need.

_typeshed is a catch here. It is a stub-only module that checkers know about and the interpreter does not, so importing it at runtime is a ModuleNotFoundError. It has to go inside an if TYPE_CHECKING block, for exactly the reason that section gave.

Pattern and Match

Pattern and Match are the annotations for compiled regular expressions and their results.

from typing import Pattern, Match
import re

pattern: Pattern[str] = re.compile(r"(\w+) Brown")
found: Match[str] | None = pattern.search("Charlie Brown and Sally Brown")

print(found.group(1) if found else None)
Charlie

Both are parameterized by str or bytes, matching whatever you compiled the pattern from. And note the | None on the Match: re.search returns None when nothing matches, which is one of the most commonly forgotten None checks in Python. Annotating it properly means the checker will force you to handle it.

Both were deprecated in Python 3.8. The replacements live in the re module itself and are spelled re.Pattern and re.Match, so prefer those:

pattern: re.Pattern[str] = re.compile(r"(\w+) Brown")
found: re.Match[str] | None = pattern.search("Charlie Brown")

ContextManager and AsyncContextManager

These annotate objects usable in a with statement, meaning anything that implements __enter__ and __exit__.

from collections.abc import Iterator
from contextlib import contextmanager
from typing import ContextManager

@contextmanager
def doghouse(name: str) -> Iterator[str]:
    print(f"{name} climbs on top")
    yield name
    print(f"{name} climbs down")

def use_it(cm: ContextManager[str]) -> None:
    with cm as value:
        print(f"Inside with: {value}")

use_it(doghouse("Snoopy"))
Snoopy climbs on top
Inside with: Snoopy
Snoopy climbs down

AsyncContextManager is the same idea for async with, requiring __aenter__ and __aexit__.

Both are deprecated aliases. The real definitions live in contextlib as contextlib.AbstractContextManager and contextlib.AbstractAsyncContextManager, and those are what you should import in new code. Note that the replacements live in contextlib, not collections.abc, which is the one exception to the pattern in the deprecated-alias section below.

Iterators, generators and the async family

This group covers eight names that all describe things you can loop over or await: Iterable, Iterator, Generator, Awaitable, Coroutine, AsyncIterable, AsyncIterator and AsyncGenerator.

The distinction between the first three trips up a lot of people, so here is the short version. Iterable means you can call iter() on it. Iterator means it has __next__, so it is the thing iter() gives you back. Generator is a specific kind of Iterator, the one produced by a function containing yield.

Generator takes three parameters, which is unusual and worth seeing:

from typing import Generator, Iterator, Iterable

def countdown(n: int) -> Generator[int, None, str]:
    while n > 0:
        yield n
        n -= 1
    return "Liftoff"

# Generator[YieldType, SendType, ReturnType]

print(list(countdown(3)))
[3, 2, 1]

SendType is for generators you push values into with .send(), and ReturnType is the value carried by StopIteration when the generator finishes. Most of the time both are None, which is why the usual advice is to annotate the return type as Iterator[int] instead. It is shorter and says everything a caller needs to know.

Since 3.13 those two parameters default to None, so Generator[int] is legal and means Generator[int, None, None]. On 3.12 the same expression raises TypeError: Too few arguments for typing.Generator; actual 1, expected 3.

The async four mirror the sync ones. Awaitable is anything you can await. Coroutine is what an async def function returns, and like Generator it takes three parameters. AsyncIterable and AsyncIterator are the async for equivalents, and AsyncGenerator is what an async def containing yield produces.

from typing import Awaitable, AsyncIterator

async def fetch_name() -> str:
    return "Snoopy"

def needs_awaitable(task: Awaitable[str]) -> None: ...

async def stream_names() -> AsyncIterator[str]:
    for name in ["Snoopy", "Linus", "Lucy"]:
        yield name

All eight are deprecated aliases for the versions in collections.abc, which is the next section.

The deprecated collection aliases

Here is where a big chunk of the module lives, and the good news is that you can dismiss almost all of it at once.

Before Python 3.9 you could not write list[int] in an annotation. The builtin types did not support subscripting, so typing provided capitalized stand-ins: List[int], Dict[str, int] and so on. PEP 585 fixed that in 3.9, and every one of these became a deprecated alias.

The rule is simple. Lowercase the name and import it from where it actually lives. For the two tables after the first that means most rows read as though nothing changed, because nothing about the spelling does: only the module you import from moves, out of typing and into collections or collections.abc.

In typingWrite instead (a builtin)
List[int]list[int]
Dict[str, int]dict[str, int]
Set[int]set[int]
FrozenSet[int]frozenset[int]
Tuple[str, int]tuple[str, int]
Type[Bank]type[Bank]
In typingWrite instead (from collections)
DefaultDict[str, int]defaultdict[str, int]
Deque[int]deque[int]
Counter[str]Counter[str]
ChainMap[str, int]ChainMap[str, int]
OrderedDict[str, int]OrderedDict[str, int]

The abstract base classes follow the same rule, just from collections.abc instead:

In typingWrite instead (from collections.abc)
Iterable[int]Iterable[int]
Iterator[int]Iterator[int]
Generator[int, None, None]Generator[int, None, None]
Sequence[str]Sequence[str]
MutableSequence[str]MutableSequence[str]
Mapping[str, int]Mapping[str, int]
MutableMapping[str, int]MutableMapping[str, int]
AbstractSet[int]Set[int] (note the rename)
MutableSet[int]MutableSet[int]
Collection[int]Collection[int]
Container[int]Container[int]
Reversible[int]Reversible[int]
HashableHashable
SizedSized
KeysView[str]KeysView[str]
ItemsView[str, int]ItemsView[str, int]
ValuesView[int]ValuesView[int]
MappingViewMappingView
Awaitable[str]Awaitable[str]
Coroutine[Any, Any, str]Coroutine[Any, Any, str]
AsyncIterable[str]AsyncIterable[str]
AsyncIterator[str]AsyncIterator[str]
AsyncGenerator[str, None]AsyncGenerator[str, None]

None of these are going away soon. PEP 585 promised nothing would be removed before the first release five years after 3.9.0, which put the earliest possible date at 3.14, and 3.14 shipped with every one of them still in typing.__all__: the table at the end of this document counts them. A deprecation that has outlived its own removal window is not one to panic about, so existing code is safe. But there is no reason to write them in anything new.

ByteString

ByteString gets its own mention because it is in worse shape than the rest. It was meant to mean bytes, bytearray or memoryview, but it never worked properly, it does not actually cover memoryview correctly, and it was deprecated in 3.9 alongside everything else.

The replacement is to write exactly what you mean:

# Instead of ByteString
def process(data: bytes | bytearray | memoryview) -> None:
    ...

# Or, if you only need a read-only buffer
from collections.abc import Buffer   # Python 3.12+

def process(data: Buffer) -> None:
    ...

Removal has already slipped once. Both typing.ByteString and collections.abc.ByteString were slated to go in 3.14, both are still exported by 3.14.6, and the docs now list them under pending removal in 3.17. Deprecated but alive is not a reason to write it. Do not use it.

dataclass_transform

This last one is the most abstract, and it is squarely aimed at library authors, so do not worry if it does not feel immediately useful.

Think about how @dataclass works. You write class attributes with annotations and no __init__, and your editor still autocompletes the constructor arguments correctly. That is not magic, it is hardcoded: type checkers ship with special-case support for @dataclass specifically.

So what happens when you write your own decorator that does the same kind of thing? Nothing. The checker has no idea, and you lose all constructor type information. @dataclass_transform is how you opt into that same special-case behaviour for your own decorator, base class or metaclass.

from typing import dataclass_transform

@dataclass_transform()
def character_model(cls):
    fields = list(cls.__annotations__)

    def __init__(self, **kwargs):
        for field in fields:
            setattr(self, field, kwargs[field])

    def __repr__(self):
        values = ", ".join(f"{f}={getattr(self, f)!r}" for f in fields)
        return f"{cls.__name__}({values})"

    cls.__init__ = __init__
    cls.__repr__ = __repr__
    return cls

@character_model
class Character:
    name: str
    age: int

snoopy = Character(name="Snoopy", age=2)
print(snoopy)
print(snoopy.name)
print(snoopy.age)
Character(name='Snoopy', age=2)
Snoopy
2

The decorator itself does nothing at runtime, it just sets a __dataclass_transform__ attribute. Everything it accomplishes happens inside the checker, which now knows to synthesize an __init__ from the annotated class attributes.

If you have ever wondered how Pydantic gets full constructor autocomplete without you writing an __init__, this is the answer. Pydantic, attrs, SQLModel and most modern ORMs all use dataclass_transform. Which is a nice place to end, because it brings us right back to the Annotated section at the start: almost everything in this module does nothing on its own, and only becomes powerful once a framework decides to read it.

A note on versions

Every name above is exported by typing in Python 3.14, and every output block above was produced by running the code on 3.14. That matters in a few places where the value is right but the spelling is not: get_type_hints prints int | None where 3.13 prints typing.Optional[int], and get_origin(Optional[int]) prints <class 'typing.Union'> where 3.13 prints typing.Union. If you are on an older version, expect those reprs to differ, and expect some of the later additions to be missing entirely.

The table below is not typed out: it is read from typing.__all__ on six real interpreters, 3.9 through 3.14, so the counts and the lists cannot drift from what the interpreters actually export.

ReleaseNamesAdded
3.977Where this table starts. 77 names already exported.
3.1084+7 Concatenate, ParamSpec, ParamSpecArgs, ParamSpecKwargs, TypeAlias, TypeGuard, is_typeddict
3.1197+13 LiteralString, Never, NotRequired, Required, Self, TypeVarTuple, Unpack, assert_never, assert_type, clear_overloads, dataclass_transform, get_overloads, reveal_type
3.1299+2 TypeAliasType, override
3.13104+5 NoDefault, ReadOnly, TypeIs, get_protocol_members, is_protocol
3.14105+1 evaluate_forward_ref

Confirm with:

python3 -c "import sys, typing; print(sys.version); print(typing.__file__)"
python3 -c "import typing; print(len(typing.__all__))"

And one last reminder that applies to every single name in this document: almost none of it is enforced at runtime. Python will happily run code that violates every annotation you wrote. All of this only pays off if you actually run mypy or pyright, ideally in CI so it cannot be skipped.