Type hints
Python is dynamically typed at runtime, but since Python 3.5 (PEP 484) the language admits type hints — optional, opt-in static annotations on parameters, return values, and variables. The runtime ignores the hints; they exist for the benefit of external type checkers (mypy, pyright, basedpyright), IDEs, and documentation tools. The mechanism is gradual typing: a codebase can adopt hints incrementally, with un-annotated code interoperating freely with annotated code. Modern Python codebases use type hints extensively; the type checkers have become substantially capable, and the typing ecosystem has grown to cover most of the language.
This page covers the type-hint syntax, the principal types, generics, the typing module, and the conventional discipline. The duck-typing side of Python’s typing story — the dynamic protocols and the typing.Protocol machinery — is in Duck typing and protocols.
The basic syntax
Type hints attach to names with a colon:
n: int = 42
name: str = "Alice"
ages: list[int] = [25, 30, 35]
mapping: dict[str, int] = {"a": 1, "b": 2}
optional_value: int | None = None
For function parameters and return values:
def greet(name: str, age: int = 0) -> str:
return f"Hello, {name}, age {age}"
def process(items: list[str]) -> dict[str, int]:
return {item: len(item) for item in items}
The type after : is the parameter’s expected type; the type after -> is the return type. The annotations are introspectable through function.__annotations__:
greet.__annotations__
# {'name': <class 'str'>, 'age': <class 'int'>, 'return': <class 'str'>}
The runtime does not check the types: greet(123, "abc") runs without error (and produces nonsense output), unless a type checker has been run against the code.
The principal types
Built-ins
Modern Python (since 3.9) admits using built-in container types as generics directly:
xs: list[int] # PEP 585; since 3.9
ys: dict[str, int]
zs: tuple[int, str, bool]
ws: set[str]
fs: frozenset[int]
For Python 3.8 and earlier, the same names are imported from typing:
from typing import List, Dict, Tuple, Set, FrozenSet
xs: List[int]
ys: Dict[str, int]
zs: Tuple[int, str, bool]
The from typing form is deprecated in modern code but remains widely used in legacy projects.
Optional and Union
A value that may be of any of several types:
from typing import Optional, Union
x: Optional[int] # Equivalent to: int | None (since 3.10)
y: Union[int, str] # Equivalent to: int | str (since 3.10)
The int | None and int | str syntax (PEP 604, since 3.10) is the modern form. For older Python, Optional[int] and Union[int, str] remain.
Any
The Any type is the escape hatch:
from typing import Any
def process(x: Any) -> Any:
return x.whatever() # type checker accepts anything
Any is compatible with every type; type-checking is effectively suspended for values typed as Any. The conventional discipline is to use Any sparingly and to narrow to a specific type as soon as possible.
Tuples
Tuples may have a fixed shape or be variable-length:
fixed: tuple[int, str, bool] # exactly three elements
variable: tuple[int, ...] # any number of ints
empty: tuple[()] # the empty tuple
The ... ellipsis is reserved for the “variable-length of one type” form.
Callables
A callable’s type is Callable[[arg_types], return_type]:
from typing import Callable
handler: Callable[[int, str], bool]
no_args: Callable[[], None]
any_args: Callable[..., Any] # any signature
The principal use is in higher-order function signatures:
def apply(f: Callable[[int], int], xs: list[int]) -> list[int]:
return [f(x) for x in xs]
Other built-in shapes
from typing import Iterable, Iterator, Generator, Sequence, Mapping
def first(xs: Iterable[int]) -> int:
for x in xs:
return x
raise ValueError("empty")
def numbers() -> Iterator[int]:
yield 1
yield 2
yield 3
These are abstract container types — Iterable[int] accepts a list, a tuple, a set, a generator, anything that admits iteration. The conventional discipline is to use the most-general type that fits — Iterable[X] for parameters that only iterate, Sequence[X] for indexed/length-known.
TypeVar and generic functions
A generic function takes a type parameter:
from typing import TypeVar
T = TypeVar("T")
def first(xs: list[T]) -> T:
return xs[0]
n: int = first([1, 2, 3])
s: str = first(["a", "b", "c"])
The T = TypeVar("T") declares a type variable; the function admits any T, and the type checker infers T from the argument type.
Bounded type variables:
from typing import TypeVar
from numbers import Number
N = TypeVar("N", bound=Number)
def doubled(x: N) -> N:
return x * 2
The bound restricts N to subtypes of Number. Multiple bounds are not directly supported; use Union or TypeVar constraints.
Constrained type variables:
StrOrBytes = TypeVar("StrOrBytes", str, bytes)
def process(x: StrOrBytes) -> StrOrBytes:
return x[:10]
The constraint admits only the listed types; unlike bound, the variable picks one of the listed types per call.
ParamSpec and Concatenate
For passing a function’s parameter list as a generic, ParamSpec:
from typing import ParamSpec, TypeVar, Callable
P = ParamSpec("P")
R = TypeVar("R")
def trace(f: Callable[P, R]) -> Callable[P, R]:
def wrapped(*args: P.args, **kwargs: P.kwargs) -> R:
print(f"calling {f.__name__}")
return f(*args, **kwargs)
return wrapped
The mechanism admits typing decorators precisely; treated in Decorators.
Generic classes
A class is made generic by inheriting from Generic[T] (older form) or, since Python 3.12, by using the new generic class syntax:
# Python 3.12+:
class Stack[T]:
def __init__(self) -> None:
self.items: list[T] = []
def push(self, item: T) -> None:
self.items.append(item)
def pop(self) -> T:
return self.items.pop()
s: Stack[int] = Stack()
s.push(42)
n: int = s.pop()
Pre-3.12:
from typing import Generic, TypeVar
T = TypeVar("T")
class Stack(Generic[T]):
def __init__(self) -> None:
self.items: list[T] = []
def push(self, item: T) -> None:
self.items.append(item)
def pop(self) -> T:
return self.items.pop()
The new syntax (PEP 695) is shorter and conventional in modern code targeting Python 3.12+.
Protocol for structural typing
The typing.Protocol admits structural typing — “any type with these members”:
from typing import Protocol
class HasLength(Protocol):
def __len__(self) -> int: ...
def total_length(things: list[HasLength]) -> int:
return sum(len(t) for t in things)
# Works for str, list, dict, set, ...
total_length(["hi", [1, 2], {"a": 1}])
The treatment is in Duck typing and protocols. The principal point: Protocol admits structural typing where nominal subtyping (inheritance) would be too restrictive.
TypedDict, Final, Literal, LiteralString
Several specialised types:
TypedDict
A dict with specific string keys and per-key value types:
from typing import TypedDict
class Person(TypedDict):
name: str
age: int
email: str | None
p: Person = {"name": "Alice", "age": 30, "email": None}
The mechanism is the conventional way to type a JSON-like dictionary structure where the keys are known at compile time.
The total=False parameter admits optional keys:
class PartialPerson(TypedDict, total=False):
name: str
age: int
The Required and NotRequired markers (since 3.11) admit per-field control:
from typing import TypedDict, Required, NotRequired
class Person(TypedDict):
name: Required[str]
age: NotRequired[int]
Final
A name that should not be reassigned:
from typing import Final
PI: Final[float] = 3.14159
SETTINGS: Final = {"key": "value"}
The type checker enforces; the runtime ignores.
Literal
A value of a specific literal:
from typing import Literal
def set_log_level(level: Literal["debug", "info", "warning", "error"]) -> None:
...
set_log_level("info") # OK
set_log_level("verbose") # type error
The mechanism admits enumeration-like type narrowing without defining an Enum.
LiteralString (3.11)
A string that is a literal or composed of literals:
from typing import LiteralString
def execute_query(query: LiteralString) -> Cursor:
...
execute_query("SELECT * FROM users WHERE id = 1") # OK; literal
execute_query("SELECT * FROM users WHERE id = " + user_id) # type error
The mechanism is the conventional defence against SQL injection at the type-check level.
Type aliases
A name for a type:
from typing import TypeAlias # Python 3.10+; before, use plain assignment
UserId: TypeAlias = int
Names: TypeAlias = list[str]
Mapping: TypeAlias = dict[str, list[int]]
Python 3.12 introduced the type statement (PEP 695):
type UserId = int
type Names = list[str]
type Result[T] = list[T] | None # generic alias
The type statement form is the conventional contemporary choice.
cast and assert_type
For cases where the type checker cannot infer the type:
from typing import cast
def find(items: list[Any]) -> Animal:
result = some_complicated_lookup(items)
return cast(Animal, result) # tell the type checker; no runtime check
cast is a no-op at runtime; it tells the type checker to treat the expression as the given type.
typing.assert_type (since 3.11) admits checking that a type is what you think:
from typing import assert_type
x = 5
assert_type(x, int) # passes; type checker errors if x is not int
The function is a no-op at runtime; the assertion is purely for the type checker.
Type narrowing
The type checker tracks how isinstance, is None, and other guards narrow a type:
from typing import Optional
def process(x: Optional[int]) -> int:
if x is None:
return 0
return x + 1 # x is int here; the type narrowed
The narrowing rules cover:
isinstance(x, T)narrowsxtoT.is Noneandis not Nonenarrow.x is True,x is Falsenarrow.assert isinstance(x, T)narrowsxthereafter.- Custom type guards via
typing.TypeGuard(3.10+) orTypeIs(3.13).
from typing import TypeGuard
def is_string_list(xs: list[Any]) -> TypeGuard[list[str]]:
return all(isinstance(x, str) for x in xs)
def process(xs: list[Any]) -> None:
if is_string_list(xs):
# xs is now list[str]
print(", ".join(xs))
The mechanism admits user-defined type-narrowing predicates.
Forward references
A type that doesn’t exist yet (a class referring to itself, a forward reference):
class Tree:
def __init__(self, value: int, children: list["Tree"]) -> None:
self.value = value
self.children = children
The "Tree" is a string; the type checker resolves it lazily. Without quotes, Tree would not be defined at the time the annotation is evaluated.
The from __future__ import annotations (PEP 563, since 3.7) admits all annotations as strings:
from __future__ import annotations
class Tree:
def __init__(self, value: int, children: list[Tree]) -> None:
...
The annotations are stored as strings and evaluated on demand. The mechanism eliminates the need for explicit forward-reference quoting and reduces import overhead. PEP 649 (deferred evaluation; targeted for Python 3.13/3.14) refines this further.
Type checkers
The principal Python type checkers:
| Tool | Notes |
|---|---|
| mypy | The original; created by Jukka Lehtosalo; widely used |
| pyright | Microsoft’s type checker; powers Pylance in VSCode; faster |
| basedpyright | Pyright fork with stricter defaults |
| pytype | Google’s type checker |
| pyre | Meta’s type checker |
For most projects, mypy or pyright is the conventional choice. They differ in defaults (pyright is stricter by default), speed (pyright is faster), and ecosystem integration (pyright is the basis for VSCode’s Python tooling).
The configuration:
# In pyproject.toml:
[tool.mypy]
strict = true
python_version = "3.12"
[tool.pyright]
strict = ["src/"]
Both tools admit incremental adoption; legacy code remains untyped while new code is annotated.
Common patterns
Type-safe configuration
from typing import TypedDict
class DatabaseConfig(TypedDict):
host: str
port: int
user: str
password: str
def connect(config: DatabaseConfig) -> Connection:
...
The TypedDict admits a JSON-like dict with type-checked keys.
Dependency injection through Protocol
from typing import Protocol
class Logger(Protocol):
def log(self, message: str) -> None: ...
def process(items: list[str], logger: Logger) -> None:
for item in items:
logger.log(f"processing {item}")
Any object with a compatible log method satisfies Logger.
Generic factory
from typing import TypeVar
T = TypeVar("T")
def first_or_default(items: list[T], default: T) -> T:
return items[0] if items else default
Result type with Union
from dataclasses import dataclass
@dataclass
class Success:
value: int
@dataclass
class Failure:
error: str
Result = Success | Failure
def compute() -> Result:
if condition:
return Success(42)
return Failure("oops")
def handle(r: Result) -> int:
match r:
case Success(value=v):
return v
case Failure(error=e):
print(e)
return 0
The pattern combines algebraic data types (via dataclass + Union) with pattern matching for an idiomatic typed result.
Self type
For methods that return self:
from typing import Self # Python 3.11+
class Builder:
def with_name(self, name: str) -> Self:
self.name = name
return self
Pre-3.11, the conventional pattern uses a TypeVar bound to the class.
A note on the conventional discipline
The contemporary Python type-hint advice:
- Type hints in new code — they are nearly universal in modern Python projects.
- Strict mode is preferable — pyright’s
strictor mypy’s--strictcatches more errors. - Use the modern syntax —
list[int]notList[int];int | NonenotOptional[int]. - Test with the type checker in CI — catches errors before review.
- Use
Anysparingly — narrow to specific types as soon as possible. - Use
Protocolfor structural typing — overABCwhen subtyping isn’t required.
The combination — gradual typing, modern syntax, mature checkers — admits Python codebases that are nearly as type-safe as statically-typed alternatives. The discipline of writing well-typed Python is a substantial part of fluency in the contemporary language.