Python’s type hints are optional, unenforced at runtime, and yet they’ve become one of the most consequential additions to the language since async/await. They don’t make Python statically typed. What they do is give you a shared vocabulary for describing shapes of data, a way to catch entire categories of bugs before your tests do, and editor support that turns “let me go check the implementation” into a tooltip.
This article covers the parts of the typing ecosystem you’ll actually reach for, the ones you’ll see in real codebases, and the tools that turn hints from documentation into enforcement.
The basics
A type hint on a variable, parameter, or return value is just an annotation. Python parses it, stores it, and otherwise ignores it at runtime:
def greet(name: str, times: int = 1) -> str:
return (f"Hello, {name}! " * times).strip()
age: int = 30
Nothing here is checked when you run the script. greet("Alice", "twice") executes without error and blows up wherever * chokes on a string times a string, if it blows up at all. The value of the hint shows up before runtime: in your editor, and in a separate type checker like mypy or pyright.
That separation is the single most important thing to understand about Python typing. Hints are metadata for tools, not runtime guards.
Collections and generics
Since Python 3.9, you can parameterize built-in collection types directly, no need to import List, Dict, or Tuple from typing:
def totals(prices: list[float]) -> float:
return sum(prices)
def word_counts(text: str) -> dict[str, int]:
counts: dict[str, int] = {}
for word in text.split():
counts[word] = counts.get(word, 0) + 1
return counts
def bounds(values: list[int]) -> tuple[int, int]:
return min(values), max(values)
tuple is the one collection where the parameterization means something different: tuple[int, int] is a 2-tuple of two ints, while tuple[int, ...] (with a literal ellipsis) is a variable-length tuple of ints. list[int] and dict[str, int] describe homogeneous containers of arbitrary length.
If you’re on Python 3.8 or earlier, you need the typing equivalents (List, Dict, Tuple) since the builtins aren’t subscriptable yet. That’s increasingly rare to encounter, but it’s why you’ll still see from typing import List in older code.
Optional and union types
Optional[X] means “X or None,” and it’s shorthand for Union[X, None]:
from typing import Optional
def find_user(user_id: int) -> Optional[str]:
if user_id == 1:
return "Alice"
return None
Since Python 3.10, the | operator works directly on types, which makes Union and Optional imports largely unnecessary:
def find_user(user_id: int) -> str | None:
if user_id == 1:
return "Alice"
return None
def parse(value: int | float | str) -> float:
return float(value)
str | None and Optional[str] are exactly equivalent, just newer syntax. Prefer | in new code targeting 3.10+; reach for Optional/Union only if you need to support older interpreters, though even then from __future__ import annotations lets you use | syntax while staying compatible at runtime (more on that below).
A common mistake is treating Optional[X] as “this parameter is optional,” i.e. has a default. It doesn’t. It only says the value, if provided, might be None. A truly optional parameter still needs = None (or another default) in the signature:
def connect(host: str, port: Optional[int] = None) -> None:
...
TypedDict for structured dicts
Plain dict[str, Any] throws away all the structure of a dict that actually has a fixed set of known keys, like a JSON API response. TypedDict lets you describe that shape:
from typing import TypedDict
class UserPayload(TypedDict):
id: int
name: str
email: str
is_admin: bool
def create_user(payload: UserPayload) -> None:
print(f"Creating {payload['name']} <{payload['email']}>")
create_user({"id": 1, "name": "Alice", "email": "alice@example.com", "is_admin": False})
A type checker will flag a missing key, a misspelled key, or a wrong value type at the call site, the same class of bug that normally only surfaces as a KeyError deep inside a function. Mark individual keys optional with total=False on a separate class, or per-field with NotRequired:
from typing import NotRequired
class UserPayload(TypedDict):
id: int
name: str
email: NotRequired[str]
TypedDict instances are still plain dicts at runtime; there’s no validation, no defaults, no methods. If you want actual runtime validation on top of the type information, that’s what a library like Pydantic is for.
Protocol for structural typing
Protocol describes an interface by shape rather than by inheritance. Anything with the right methods satisfies the protocol, no explicit subclassing required, this is Python’s version of duck typing made explicit and checkable:
from typing import Protocol
class SupportsClose(Protocol):
def close(self) -> None: ...
def cleanup(resource: SupportsClose) -> None:
resource.close()
class DatabaseConnection:
def close(self) -> None:
print("closing connection")
class FileHandle:
def close(self) -> None:
print("closing file")
cleanup(DatabaseConnection())
cleanup(FileHandle())
Neither DatabaseConnection nor FileHandle inherits from SupportsClose. They satisfy it purely by having a matching close method. This is exactly how much of the standard library’s own typing works: open() returns something that satisfies file-like protocols, and code written against Protocol types accepts any object with the right shape instead of demanding a specific base class.
Generics with type parameters
When a function or class’s behavior is generic over some type, you can express that relationship instead of falling back to Any. Since Python 3.12, the syntax is built into function and class definitions:
def first[T](items: list[T]) -> T:
return items[0]
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()
A type checker infers that first([1, 2, 3]) returns int, and that Stack[str]().pop() returns str, without you writing a single Any. On Python 3.11 and earlier, you write this with TypeVar instead:
from typing import TypeVar, Generic
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()
Both forms mean the same thing to a type checker; the 3.12 syntax is just less boilerplate.
Literal, Final, and overloads
Literal narrows a type to specific values rather than a whole type. It’s the right tool for flags, modes, and small closed sets of strings that aren’t quite worth an Enum:
from typing import Literal
def set_log_level(level: Literal["debug", "info", "warning", "error"]) -> None:
print(f"log level set to {level}")
set_log_level("info")
set_log_level("verbose") # type checker error: not a valid literal
Final marks a name as never meant to be reassigned:
from typing import Final
MAX_RETRIES: Final = 3
@overload lets you describe a function whose return type depends on its argument types, something a single signature can’t express:
from typing import overload
@overload
def parse(value: str) -> str: ...
@overload
def parse(value: bytes) -> str: ...
@overload
def parse(value: None) -> None: ...
def parse(value):
if value is None:
return None
return value.decode() if isinstance(value, bytes) else value
Type checkers use the @overload stubs to pick the right return type at each call site; only the final, un-decorated implementation actually runs.
Deferred evaluation of annotations
Two forward-reference problems come up constantly: referring to a class inside its own methods before the class is fully defined, and wanting | union syntax on a Python version where it isn’t valid at runtime. Both are solved by deferring annotation evaluation:
from __future__ import annotations
class Node:
def __init__(self, value: int, parent: Node | None = None) -> None:
self.value = value
self.parent = parent
With from __future__ import annotations, every annotation in the module is treated as a string and never evaluated at runtime, so Node can reference itself and int | None works even on Python 3.9. Type checkers still parse and check the string, they just don’t need Python itself to evaluate it. This import is cheap enough that many teams add it to every module by default.
Runtime enforcement with mypy
None of this matters if nothing ever checks it. mypy is the reference type checker; pyright (bundled with Pyright/Pylance in VS Code) is the other major option and tends to be faster and stricter by default.
pip install mypy
mypy src/
A minimal pyproject.toml config that catches real bugs without demanding you annotate an entire legacy codebase overnight:
[tool.mypy]
python_version = "3.12"
warn_return_any = true
warn_unused_ignores = true
disallow_untyped_defs = false
check_untyped_defs = true
disallow_untyped_defs = false lets you adopt hints incrementally, file by file, function by function, while check_untyped_defs = true still type-checks the bodies of unannotated functions using whatever inference it can do. Flip disallow_untyped_defs to true once a module is fully annotated to lock in the coverage.
Real-world pattern: typed configuration objects
Passing loosely-typed dicts around for configuration is a classic source of “wait, is it db_host or dbHost?” bugs. A typed dataclass turns that into something both readable and checked:
from dataclasses import dataclass
@dataclass(frozen=True)
class DatabaseConfig:
host: str
port: int = 5432
username: str = "postgres"
password: str = ""
pool_size: int = 10
def connect(config: DatabaseConfig) -> None:
print(f"connecting to {config.host}:{config.port} as {config.username}")
config = DatabaseConfig(host="db.internal", password="secret")
connect(config)
Misspell a field name in the constructor call and mypy catches it before the code runs. frozen=True adds immutability on top, so config objects can’t be mutated after construction, a nice property for anything passed around between modules.
Real-world pattern: validating function boundaries with Protocol
Protocol is especially useful for keeping application code decoupled from a specific dependency, letting you swap implementations (including a fake for tests) without touching call sites:
from typing import Protocol
class Cache(Protocol):
def get(self, key: str) -> str | None: ...
def set(self, key: str, value: str) -> None: ...
class RedisCache:
def __init__(self, client) -> None:
self._client = client
def get(self, key: str) -> str | None:
return self._client.get(key)
def set(self, key: str, value: str) -> None:
self._client.set(key, value)
class InMemoryCache:
def __init__(self) -> None:
self._data: dict[str, str] = {}
def get(self, key: str) -> str | None:
return self._data.get(key)
def set(self, key: str, value: str) -> None:
self._data[key] = value
def get_or_compute(cache: Cache, key: str, compute) -> str:
value = cache.get(key)
if value is None:
value = compute()
cache.set(key, value)
return value
get_or_compute doesn’t know or care whether it’s given a RedisCache or an InMemoryCache. Tests can pass InMemoryCache() with zero mocking, and the Protocol guarantees at check time that any future cache implementation actually implements the methods the function needs.
Gotchas
- Hints are not validation.
def f(x: int)does nothing to stopf("not an int")at runtime. If you need real enforcement at a boundary (parsing user input, an API request body), use a validation library, hints alone won’t save you. Anydefeats the type checker silently. Every value typedAny(or inferred asAnybecause an import couldn’t be resolved) turns off checking for everything downstream of it. A few unavoidableAnys are fine; a codebase full of them gives false confidence.- Mutable default arguments are still a bug, hints or not.
def f(items: list[int] = [])is exactly as broken as the untyped version, the type hint doesn’t fix Python’s shared-default-object behavior. UseNoneand initialize inside the function. - Variance surprises with generics.
list[int]is not treated as alist[float]even thoughintis assignable tofloat, becauselistis mutable and invariant. This trips people up coming from languages with more permissive generic variance rules. - Runtime
isinstancechecks against generic aliases mostly don’t work.isinstance(x, list[int])raisesTypeError. Generics are a static-checking concept; at runtime you can only check the bare container type:isinstance(x, list).
Summary
| Feature | Syntax | Use case |
|---|---|---|
| Basic hints | def f(x: int) -> str: | Document and check parameter/return types |
| Collection generics | list[int], dict[str, int] | Parameterize built-in containers |
| Union / Optional | str | None, Optional[str] | Value may be one of several types, or absent |
| TypedDict | class P(TypedDict): id: int | Describe the shape of a structured dict |
| Protocol | class P(Protocol): def close(self) -> None: ... | Structural typing, duck typing made explicit |
| Generics | class Stack[T]: / TypeVar | Types parameterized over another type |
| Literal | Literal["debug", "info"] | Restrict to a specific closed set of values |
| Final | MAX: Final = 3 | Mark a name as never reassigned |
| overload | @overload | Return type depends on argument type |
| Deferred eval | from __future__ import annotations | Forward references and newer union syntax on older Python |
Type hints don’t change what Python runs, they change what tools can tell you about the code before it runs. Adopted incrementally, with mypy or pyright in CI, they turn a class of bugs that used to surface in production into a red squiggle in your editor.