Why type hints matter
Python is dynamically typed — a variable can hold any value at any time, and the interpreter never checks what type you intended. For a 50-line script that runs once, this is a feature. For a 50,000-line codebase with five developers, it becomes the primary source of runtime bugs: passing a string where an integer was expected, returning None from a function that callers assume always returns a list, or misremembering the shape of a dict that got passed three function calls deep.
Type hints, introduced in PEP 484 (Python 3.5) and expanded with every release since, address this by letting you annotate exactly what types a function accepts and returns. The annotations don't change how Python runs your code — they're metadata. What they unlock is static analysis: running a tool like mypy before deployment to catch type errors the same way a compiler catches them in Go or Java.
Here is a realistic before-and-after. The untyped version has a bug that is invisible until production:
# No type hints — the bug is invisible
def get_user_age(user):
return user["age"]
def is_adult(user):
return get_user_age(user) >= 18
# Caller passes age as a string — fails at runtime with TypeError
user = {"name": "Alice", "age": "30"} # age is "30", not 30
print(is_adult(user)) # TypeError: '>=' not supported between 'str' and 'int'
# With type hints — mypy catches the bug before the code ever runs
def get_user_age(user: dict[str, int]) -> int:
return user["age"]
def is_adult(user: dict[str, int]) -> bool:
return get_user_age(user) >= 18
# mypy output: error: Dict entry "age": expected "int", got "str"
user = {"name": "Alice", "age": "30"}
The annotation doesn't fix the bug — it makes it impossible to miss. This is the core value proposition: shift errors from runtime surprises to static analysis findings that your CI pipeline catches on every push.
Variables and function annotations
The syntax for annotating a variable is a colon after the name, followed by the type. For functions, parameters get annotated inline and the return type follows the closing parenthesis with ->:
# Variable annotations
name: str = "Alice"
age: int = 30
score: float = 9.5
active: bool = True
# Function annotations
def greet(name: str) -> str:
return f"Hello, {name}!"
def add(x: int, y: int) -> int:
return x + y
# Functions that return nothing use -> None
def log_message(msg: str) -> None:
print(f"[LOG] {msg}")
# Default parameter values come after the annotation
def repeat(text: str, times: int = 1) -> str:
return text * times
Beyond the four scalar types (str, int, float, bool), you will regularly annotate collections. Before Python 3.9, you had to import container types from the typing module. From 3.9 onward, the built-in types accept subscript syntax directly:
# Pre-3.9: had to import from typing
from typing import List, Dict, Tuple, Set
def old_style(names: List[str]) -> Dict[str, int]:
return {name: len(name) for name in names}
# Python 3.9+: use built-in types directly
def new_style(names: list[str]) -> dict[str, int]:
return {name: len(name) for name in names}
# Tuples: specify the type of each element
def get_coordinates() -> tuple[float, float]:
return (51.5074, -0.1278)
# Tuple of variable length (all same type): tuple[int, ...]
def sum_all(values: tuple[int, ...]) -> int:
return sum(values)
# Sets and frozensets
def unique_words(text: str) -> set[str]:
return set(text.lower().split())
from __future__ import annotations at the top of the file. This defers all annotation evaluation and allows the newer syntax on older interpreters.Annotating class attributes and methods
class User:
# Class-level annotation without value = instance attribute declaration
name: str
email: str
age: int
def __init__(self, name: str, email: str, age: int) -> None:
self.name = name
self.email = email
self.age = age
def is_adult(self) -> bool:
return self.age >= 18
# Class methods annotate 'cls' as type[User]
@classmethod
def from_dict(cls: "type[User]", data: dict[str, str | int]) -> "User":
return cls(
name = str(data["name"]),
email = str(data["email"]),
age = int(data["age"]),
)
Built-in generics (Python 3.9+)
Python 3.9 made every standard collection type generic by default — you no longer need to import capitalised versions from typing. Here is the full picture of what changed and how to use each type correctly:
# list[T]
def first(items: list[int]) -> int:
return items[0]
# dict[KeyType, ValueType]
def count_chars(text: str) -> dict[str, int]:
counts: dict[str, int] = {}
for ch in text:
counts[ch] = counts.get(ch, 0) + 1
return counts
# tuple[T1, T2] — fixed-length, each element typed separately
Point = tuple[float, float]
def distance(a: Point, b: Point) -> float:
import math
return math.hypot(b[0] - a[0], b[1] - a[1])
# set[T]
def intersection(a: set[str], b: set[str]) -> set[str]:
return a & b
# Nested generics: list of dicts
def get_names(records: list[dict[str, str]]) -> list[str]:
return [r["name"] for r in records]
The collections.abc module provides abstract container types that are more flexible than the concrete ones. Using these for function parameters is better practice because it accepts any iterable, not just lists:
from collections.abc import Sequence, Iterable, Callable, Generator
# Sequence: any ordered type with len() and indexing (list, tuple, str)
def last(items: Sequence[int]) -> int:
return items[-1]
# Iterable: anything you can iterate — most permissive
def total(values: Iterable[int]) -> int:
return sum(values)
# Callable[[arg_types], return_type]
def apply_twice(fn: Callable[[int], int], x: int) -> int:
return fn(fn(x))
print(apply_twice(lambda n: n * 2, 3)) # 12
# Generator[YieldType, SendType, ReturnType]
def countdown(n: int) -> Generator[int, None, str]:
while n > 0:
yield n
n -= 1
return "done"
Sequence over list for function parameters when you don't mutate the input — it makes the function work with tuples, strings, and any other ordered sequence, not just lists. Reserve list[T] for parameters you actually modify or return.Union and Optional
Sometimes a value can legitimately be one of several types. Before Python 3.10, Union from the typing module handled this. Python 3.10 introduced the | operator, which is now the preferred syntax:
from typing import Union # only needed pre-3.10
# Pre-3.10 syntax
def old_parse(value: Union[str, int, None]) -> str:
return str(value)
# Python 3.10+ syntax — cleaner and identical in meaning
def new_parse(value: str | int | None) -> str:
return str(value)
# Real example: parsing a config value that might be an int or a string
def get_port(config: dict[str, str | int]) -> int:
raw = config["port"]
if isinstance(raw, str):
return int(raw)
return raw # mypy knows raw is int here — narrowing
The most common union is T | None — a value that might be missing. This was previously spelled Optional[T], which is still valid but now considered the legacy form:
from typing import Optional
# These three are all equivalent
def a(x: Optional[str]) -> None: ... # legacy typing.Optional
def b(x: str | None) -> None: ... # preferred Python 3.10+
def c(x: Union[str, None]) -> None: ... # verbose Union form
# Always narrow before using an Optional value
def greet(name: str | None) -> str:
if name is None:
return "Hello, stranger!"
return f"Hello, {name}!"
# After the if check, mypy knows name is str — no need to cast
# Type narrowing with isinstance also works
def double(x: int | str) -> int | str:
if isinstance(x, int):
return x * 2 # mypy: x is int here
return x + x # mypy: x is str here
Type narrowing — where mypy infers a more specific type after a conditional check — is one of the most powerful features of the type system. After if name is None: return, mypy knows that on any code path that continues, name must be a str. No cast needed.
Type aliases
When the same complex type appears repeatedly, name it. Type aliases make signatures readable and create a single point of change if the type evolves:
from typing import TypeAlias
# Without alias: hard to read
def process(data: dict[str, list[dict[str, str | int]]]) -> list[str]:
...
# With alias: clear intent
Row: TypeAlias = dict[str, str | int]
DataTable: TypeAlias = dict[str, list[Row]]
def process(data: DataTable) -> list[str]:
...
# Python 3.12 introduced 'type' statement (even cleaner)
type UserId = int
type UserMap = dict[UserId, str]
# Simple assignment works for simple aliases in 3.9+
Vector = list[float]
Matrix = list[Vector]
def dot_product(v1: Vector, v2: Vector) -> float:
return sum(a * b for a, b in zip(v1, v2))
def transpose(m: Matrix) -> Matrix:
return [list(row) for row in zip(*m)]
# Domain-specific alias makes argument intent obvious
Url = str
HtmlBody = str
def fetch_page(url: Url) -> HtmlBody:
...
TypedDict for structured dicts
Plain dict[str, Any] abandons type safety entirely — you lose all checking on the values. TypedDict solves this by letting you define the exact expected keys and their individual types for a dictionary structure. It is ideal for JSON responses, config objects, and any dict with a fixed schema.
from typing import TypedDict
# Define the schema for a user record
class User(TypedDict):
id: int
name: str
email: str
def get_display_name(user: User) -> str:
return user["name"]
# mypy catches this: 'age' is not a valid key for User
alice: User = {"id": 1, "name": "Alice", "email": "alice@example.com"}
wrong: User = {"id": 2, "name": "Bob", "age": 25} # error: extra key 'age'
TypedDict supports optional keys using Required and NotRequired (Python 3.11+), or via inheritance to split required and optional groups:
from typing import TypedDict, NotRequired, Required
class Product(TypedDict):
id: int # required
name: str # required
description: NotRequired[str] # optional — may be absent
discount: NotRequired[float] # optional
def render_product(p: Product) -> str:
desc = p.get("description", "No description available")
return f"{p['name']}: {desc}"
# Valid even without optional keys
item: Product = {"id": 1, "name": "Keyboard"}
# Alternative for pre-3.11: split required and optional via inheritance
class _ProductRequired(TypedDict):
id: int
name: str
class Product(_ProductRequired, total=False):
description: str # total=False makes these optional
discount: float
TypedDict is particularly useful when working with APIs. Instead of annotating the return type of a JSON-parsing function as dict[str, Any] and losing all type information downstream, you define a TypedDict for the expected response shape, giving mypy visibility into every field access.
Protocol — structural subtyping
In traditional object-oriented typing, a class satisfies a type constraint only if it explicitly inherits from that type. Protocol takes a different approach: a class satisfies a Protocol if it has the required attributes and methods, regardless of its inheritance chain. This is called structural subtyping, or "static duck typing."
from typing import Protocol
# Define what "Drawable" means — any object with a draw() method
class Drawable(Protocol):
def draw(self) -> None: ...
# These classes never heard of Drawable — they just happen to have draw()
class Circle:
def draw(self) -> None:
print("Drawing a circle")
class Rectangle:
def draw(self) -> None:
print("Drawing a rectangle")
class Button:
def click(self) -> None: # no draw() method
print("Button clicked")
# This function accepts any Drawable — mypy checks structurally
def render_all(shapes: list[Drawable]) -> None:
for shape in shapes:
shape.draw()
render_all([Circle(), Rectangle()]) # OK — both have draw()
render_all([Button()]) # error: Button has no draw()
This is powerful for library design because callers never need to import your Protocol or modify their classes — they just need to provide the right interface:
from typing import Protocol, runtime_checkable
# runtime_checkable makes isinstance() work with the Protocol
@runtime_checkable
class Closeable(Protocol):
def close(self) -> None: ...
# Protocol with attributes and methods
class Serializable(Protocol):
id: int
name: str
def to_dict(self) -> dict[str, str | int]: ...
def serialize_batch(items: list[Serializable]) -> list[dict[str, str | int]]:
return [item.to_dict() for item in items]
# Any class with id, name, and to_dict() qualifies — no imports required
class Product:
def __init__(self, id: int, name: str):
self.id = id
self.name = name
def to_dict(self) -> dict[str, str | int]:
return {"id": self.id, "name": self.name}
serialize_batch([Product(1, "Keyboard")]) # mypy: OK
Literal and Final
Literal restricts a type to a specific set of allowed values, not just a type. This is ideal for function parameters that only make sense with particular strings or integers:
from typing import Literal
# Only "asc" or "desc" are valid directions
Direction = Literal["asc", "desc"]
def sort_users(users: list[str], direction: Direction) -> list[str]:
return sorted(users, reverse=(direction == "desc"))
sort_users(["Bob", "Alice"], "asc") # OK
sort_users(["Bob", "Alice"], "up") # error: "up" is not Literal["asc", "desc"]
# HTTP methods
HttpMethod = Literal["GET", "POST", "PUT", "DELETE", "PATCH"]
def make_request(method: HttpMethod, url: str) -> None:
print(f"{method} {url}")
# Integer literals work too
LogLevel = Literal[1, 2, 3] # DEBUG=1, INFO=2, ERROR=3
Final marks a variable or attribute as a constant — mypy will raise an error if any code tries to reassign it:
from typing import Final
MAX_RETRIES: Final = 3
API_BASE: Final[str] = "https://api.example.com/v1"
TIMEOUT: Final[float] = 30.0
# mypy error: Cannot assign to final name "MAX_RETRIES"
MAX_RETRIES = 5
# Final in a class means the method cannot be overridden in subclasses
from typing import final
class Base:
@final
def compute_id(self) -> str:
return str(id(self))
class Child(Base):
def compute_id(self) -> str: # error: Cannot override final method
return "custom"
Overload for multiple signatures
Some functions have different return types depending on what arguments you pass. The @overload decorator lets you declare multiple signatures for a single function so mypy can infer the correct return type at each call site:
from typing import overload
# Without @overload, mypy cannot infer that int input → int output
# and str input → str output. It can only say: returns int | str.
@overload
def double(x: int) -> int: ...
@overload
def double(x: str) -> str: ...
# The actual implementation (not type-checked at call sites)
def double(x: int | str) -> int | str:
if isinstance(x, int):
return x * 2
return x + x
# mypy now knows the types precisely
result_int: int = double(5) # OK — returns int
result_str: str = double("hi") # OK — returns str
result_bad: int = double("hi") # error: "str" is not "int"
@overload decorator, with a union signature broad enough to cover all overloads.Generics with TypeVar
A generic function works with a family of types while preserving the relationship between inputs and outputs. TypeVar is the tool for expressing "whatever type you pass in, I return the same type":
from typing import TypeVar
T = TypeVar("T")
# Without TypeVar: mypy cannot tell what type first() returns
def first_untyped(items: list) -> ...: # would be 'Any' — useless
return items[0]
# With TypeVar: mypy knows if you pass list[str], you get str back
def first(items: list[T]) -> T:
return items[0]
name: str = first(["Alice", "Bob"]) # OK — T bound to str
num: int = first([1, 2, 3]) # OK — T bound to int
bad: int = first(["Alice", "Bob"]) # error: got str, expected int
Use bound to constrain the TypeVar to a type or its subclasses, or constraints to limit it to an explicit set:
from typing import TypeVar
from collections.abc import Comparable
# bound: T must be a subclass of the bound type
Comparable = TypeVar("Comparable", bound=float)
def clamp(value: Comparable, lo: Comparable, hi: Comparable) -> Comparable:
return max(lo, min(value, hi))
# constraints: T must be exactly one of the listed types
AnyStr = TypeVar("AnyStr", str, bytes)
def encode_if_str(data: AnyStr) -> AnyStr:
if isinstance(data, str):
return data.upper() # type: ignore[return-value]
return data
# Python 3.12 introduces cleaner syntax with type parameters
# (no TypeVar import needed)
def last[T](items: list[T]) -> T: # Python 3.12+ only
return items[-1]
Generic classes
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()
def peek(self) -> T | None:
return self._items[-1] if self._items else None
# mypy infers the specific type for each Stack instance
int_stack: Stack[int] = Stack()
int_stack.push(42)
int_stack.push("oops") # error: expected int, got str
str_stack: Stack[str] = Stack()
str_stack.push("hello") # OK
Running mypy in CI
Type hints have no value if you never actually check them. The point is to run mypy as part of your CI pipeline so that every pull request gets type-checked before it can merge.
Installation and basic usage
# Install mypy
pip install mypy
# Type-check a single file
mypy mymodule.py
# Type-check an entire package
mypy src/
# Strict mode: enables all optional checks
mypy --strict src/
# Check specific flags
mypy --disallow-untyped-defs --disallow-any-generics src/
mypy configuration (mypy.ini or pyproject.toml)
[tool.mypy]
python_version = "3.11"
strict = true # enables all checks below
warn_return_any = true
warn_unused_configs = true
disallow_untyped_defs = true # all functions must be annotated
disallow_any_generics = true # no bare list, dict — must be list[str] etc.
check_untyped_defs = true
no_implicit_reexport = true
# Per-module overrides for third-party code without stubs
[[tool.mypy.overrides]]
module = ["requests.*", "boto3.*"]
ignore_missing_imports = true
GitHub Actions integration
name: Type Check
on: [push, pull_request]
jobs:
mypy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: "3.11"
cache: pip
- name: Install dependencies
run: |
pip install -e ".[dev]"
pip install mypy
- name: Run mypy
run: mypy src/ --strict
[[tool.mypy.overrides]] block to ignore modules you haven't typed yet, then progressively tighten the scope as you annotate them.Common mypy errors and fixes
Here are the errors you will see most often, what they mean, and how to resolve them properly:
# Error: Item "None" of "str | None" has no attribute "upper"
def bad(name: str | None) -> str:
return name.upper() # error! name might be None
# Fix: narrow the type first
def good(name: str | None) -> str:
if name is None:
return ""
return name.upper() # mypy now knows name is str
# ─────────────────────────────────────────────
# Error: Incompatible return value type (got "int", expected "str")
def bad_return(x: int) -> str:
return x # error: x is int, not str
# Fix: convert explicitly
def good_return(x: int) -> str:
return str(x)
# ─────────────────────────────────────────────
# Error: Argument 1 has incompatible type "str"; expected "int"
def add(x: int, y: int) -> int:
return x + y
user_input = input("Enter number: ") # input() returns str
add(1, user_input) # error: user_input is str
# Fix: parse before passing
add(1, int(user_input))
# ─────────────────────────────────────────────
# Error: Need type annotation for "items" (hint: "items: list[<type>] = []")
items = [] # mypy cannot infer the element type of an empty list
# Fix: annotate the variable explicitly
items: list[str] = []
# ─────────────────────────────────────────────
# Using cast() as a last resort (avoid if possible)
from typing import cast
raw: object = get_something()
typed = cast(str, raw) # tells mypy "trust me, raw is str"
# cast() has ZERO runtime effect — it is a pure type annotation
# Use only when you know more than mypy does and cannot narrow normally
One final pattern worth knowing: when you cannot avoid Any (e.g., for dynamic data from external sources), confine it to the boundary. Parse the external input into typed structures as early as possible, and let mypy's type narrowing take over from there. The goal is not to eliminate Any everywhere — it is to prevent it from spreading beyond the I/O layer.