Python Type Hints
Type hints let you annotate variables, function parameters, and return values with the types they are expected to hold. Python remains a dynamically typed language at runtime — hints are never enforced automatically — but they let editors, static analyzers like mypy and pyright, and other developers understand your code’s intent, catch bugs before the program ever runs, and get accurate autocomplete. In large codebases, type hints are the difference between confidently refactoring and being afraid to touch anything.
Overview / How it works
Introduced in PEP 484, type hints are optional annotations attached to function signatures and variables using a colon (:) for parameters/variables and an arrow (->) for return types. Crucially, the Python interpreter does not check these types at runtime. When CPython compiles a function, it evaluates the annotation expressions once (usually at import time) and stores the results in a special dictionary attribute, __annotations__, on the function or module object. That’s it — no enforcement, no coercion, no exception if you violate the hint. The actual type checking is performed by separate, external static analysis tools that read your source code and your annotations and report mismatches before you ever run the program.
This separation is deliberate. It means type hints have effectively zero runtime performance cost (aside from evaluating the annotation expressions once), and it means you can adopt typing incrementally — a single function, a single module, or an entire project — without changing runtime behavior at all. Since Python 3.9, the built-in generic container types (list, dict, tuple, set) can be parameterized directly (list[int]), so you rarely need to import List, Dict, etc. from the typing module anymore. Since Python 3.10, you can write union types with the pipe operator, int | str, instead of typing.Union[int, str].
Syntax
def function_name(param1: Type1, param2: Type2 = default) -> ReturnType:
...
variable_name: Type = value
| Piece | Meaning |
|---|---|
param: Type |
Annotates a parameter’s expected type |
-> ReturnType |
Annotates the function’s return type (use -> None if nothing is returned) |
variable: Type = value |
Annotates a variable at assignment (works at module, class, and local scope) |
Type1 | Type2 |
Union — the value may be either type (Python 3.10+) |
Type | None |
Equivalent to Optional[Type] — value may be None |
list[Type], dict[K, V] |
Parameterized built-in generics (Python 3.9+) |
Common typing building blocks
| Construct | Use |
|---|---|
Any |
Opts out of type checking for a value entirely |
Optional[X] |
Shorthand for X | None |
Union[X, Y] |
Older equivalent of X | Y |
Callable[[Args], Ret] |
A function/callable’s signature |
TypeVar |
Defines a generic placeholder type reused across a signature |
Literal["a", "b"] |
Restricts a value to specific literal constants |
Protocol |
Structural typing — “anything with this method” (duck typing, checked statically) |
Examples
Example 1: Basic function annotations
def greet(name: str, age: int) -> str:
return f"Hello {name}, you are {age} years old."
print(greet("Alice", 30))
Output:
Hello Alice, you are 30 years old.
Here name is annotated as str, age as int, and the function’s return type is str. A static checker reading this code would flag a call like greet(30, "Alice") as an error, even though Python itself would happily run it (and produce a different but still valid string).
Example 2: Optional return values and parameterized generics
from typing import Optional
def find_user(user_id: int, users: dict[int, str]) -> Optional[str]:
return users.get(user_id)
users = {1: "Alice", 2: "Bob"}
print(find_user(1, users))
print(find_user(3, users))
Output:
Alice
None
dict[int, str] tells readers and checkers that users maps integer IDs to string names. The return type Optional[str] (equivalent to str | None) documents that this function might return None when the key is missing — which is exactly what dict.get does. A caller who forgets to handle the None case will get a warning from a type checker before it becomes a production AttributeError.
Example 3: Dataclasses and generic functions with TypeVar
from dataclasses import dataclass
from typing import TypeVar, Sequence
T = TypeVar("T")
@dataclass
class Inventory:
name: str
quantity: int = 0
def total_quantity(items: Sequence[Inventory]) -> int:
return sum(item.quantity for item in items)
def first_item(items: Sequence[T]) -> T:
return items[0]
stock = [Inventory("Widget", 10), Inventory("Gadget", 5)]
print(total_quantity(stock))
print(first_item(stock))
Output:
15
Inventory(name='Widget', quantity=10)
@dataclass uses the class body’s type annotations (name: str, quantity: int = 0) to auto-generate __init__, __repr__, and __eq__. The TypeVar named T lets first_item stay generic: a checker knows that whatever type of sequence you pass in, you get one element of that same type back — here, an Inventory, not just “some object”.
Under the hood
- When Python parses a function definition, it evaluates every annotation expression (like
str,dict[int, str], or a custom class) once, at definition time. - The results are collected into a
__annotations__dictionary attached to the function object, mapping parameter names (and"return") to the evaluated type objects. - Nothing else happens automatically. Calling the function does not consult
__annotations__at all — there is no runtime cast or check. - Static type checkers (
mypy,pyright,pyre) parse your source independently, build a model of every variable’s inferred or declared type, and cross-check every assignment and call against that model — entirely without executing your code. - Libraries like
dataclasses,pydantic, andattrsare exceptions: they explicitly read__annotations__at class-creation time to generate real runtime behavior (like__init__). That’s a deliberate opt-in by the library, not something the language does for you automatically.
Common Mistakes
Mistake 1: Assuming type hints are enforced at runtime
Beginners often expect a TypeError when a hinted type is violated. It never comes:
def add(a: int, b: int) -> int:
return a + b
result = add("3", "4")
print(result)
Output:
34
Python happily concatenates the two strings instead of raising an error, because a and b are hints, not runtime guards. If you need real runtime validation (for example, of user input or API payloads), use a library built for that, like pydantic, or add explicit isinstance checks yourself — type hints alone won’t protect you at runtime.
Mistake 2: Mutable default arguments hiding behind a type hint
This bug exists with or without type hints, but a hint can make it look more “official” and easier to miss:
def append_item(item: str, items: list[str] = []) -> list[str]:
items.append(item)
return items
print(append_item("a"))
print(append_item("b"))
Output:
['a']
['a', 'b']
The default list is created once, when the function is defined, and reused across every call that doesn’t supply its own. The fix is to default to None and create a fresh list inside the function:
def append_item(item: str, items: list[str] | None = None) -> list[str]:
if items is None:
items = []
items.append(item)
return items
print(append_item("a"))
print(append_item("b"))
Now each call without an explicit items argument gets its own empty list, and the list[str] | None hint honestly documents that None is a valid input.
Best Practices
- Prefer built-in generics (
list[int],dict[str, int]) over importingList/Dictfromtyping— they’ve been redundant since Python 3.9. - Use
X | Noneinstead ofOptional[X]on Python 3.10+ for consistency with union syntax elsewhere. - Annotate public function signatures first — that’s where hints pay off most for callers and tooling; add them to internal helpers as time allows.
- Run a static checker (
mypy,pyright) in CI so hints are actually verified, not just decorative. - Avoid
Anyexcept as a deliberate, temporary escape hatch; overusing it silently disables checking wherever it appears. - Use
TYPE_CHECKINGfromtypingto guard imports that are only needed for annotations, avoiding circular-import issues at runtime. - Reach for
Protocolwhen you want to describe “anything with this method” (structural typing) instead of forcing unrelated classes to share a base class. - Keep hints honest: a return type of
stron a function that can returnNoneis worse than no hint at all, because it actively misleads readers and checkers.
Practice Exercises
- Write a function
average(numbers: list[float]) -> floatthat returns the mean of a list of numbers, and call it with a mix of ints and floats to confirm it still works. - Write a function
parse_age(raw: str) -> int | Nonethat returns the parsed integer ifrawis a valid digit string, orNoneotherwise (hint: usestr.isdigit()). - Define a generic function
last_item(items: Sequence[T]) -> TusingTypeVar, and call it with a list of strings and a list of integers to see how the inferred type changes with each call.
Summary
- Type hints annotate expected types for variables, parameters, and return values, but Python never enforces them at runtime.
- Annotations are stored in a function or module’s
__annotations__dictionary; static tools likemypyread source code to actually verify them. - Since Python 3.9/3.10, prefer built-in generics (
list[int]) and pipe unions (X | None) over the oldertypingmodule equivalents. - Libraries such as
dataclassesandpydanticdeliberately read annotations to generate real runtime behavior — that’s the exception, not the rule. - Use
TypeVarandProtocolfor generic and structural typing; use a real validation library, not hints, when you need runtime guarantees.
