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: intBy 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_thanFastAPI 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 charactersThat 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 29I 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.17The 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 = nextInternally, 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 = nextLiteral
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) # Correcterror: 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] = 26example2[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 ChaseYour 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 = 24The 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: 67Item: 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.2Print 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 itThe 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.5124.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, DramaticSnoopy 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 charactersRunning: 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 stringSnoopy, 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: 25str 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 syntaxThe 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 batteryBalancing 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 + bText
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 nameAll 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 typing | Write 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 typing | Write 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 typing | Write 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] |
| Hashable | Hashable |
| Sized | Sized |
| KeysView[str] | KeysView[str] |
| ItemsView[str, int] | ItemsView[str, int] |
| ValuesView[int] | ValuesView[int] |
| MappingView | MappingView |
| 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.
| Release | Names | Added |
|---|---|---|
| 3.9 | 77 | Where this table starts. 77 names already exported. |
| 3.10 | 84 | +7 Concatenate, ParamSpec, ParamSpecArgs, ParamSpecKwargs, TypeAlias, TypeGuard, is_typeddict |
| 3.11 | 97 | +13 LiteralString, Never, NotRequired, Required, Self, TypeVarTuple, Unpack, assert_never, assert_type, clear_overloads, dataclass_transform, get_overloads, reveal_type |
| 3.12 | 99 | +2 TypeAliasType, override |
| 3.13 | 104 | +5 NoDefault, ReadOnly, TypeIs, get_protocol_members, is_protocol |
| 3.14 | 105 | +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.