Static Type Checking in Python: How mypy Reads Your Type Hints
- Python itself never checks type annotations at runtime —
def add(x: int, y: int) -> inthappily accepts strings and returns whatever falls out, hints or no hints. - mypy reads the same annotations statically, before the program ever runs, and reports a mismatch as an error in your editor or CI pipeline instead of a crash in production.
- The single most common class of error mypy catches is a value that might be
Nonebeing used somewhere that assumes it can't be — exactly the case a type checker is built to prevent. - Typing is gradual: a file, function, or variable with no annotation at all is simply not checked, so mypy can be adopted incrementally on an existing codebase rather than needing every line annotated up front.
Type hints are not enforced at runtime
A common surprise for anyone new to Python's type hints: writing them changes nothing about how the code actually runs.
def add(x: int, y: int) -> int:
return x + y
add("hello", "world") # runs fine, returns 'helloworld'
Python evaluates annotations, stores them (accessible via __annotations__), and then completely ignores them for the purpose of actually calling the function. Nothing raises a TypeError here, because the annotations are documentation and tooling metadata, not a runtime contract — that's precisely the gap a static type checker like mypy is built to fill.
Running mypy for the first time
mypy is a separate tool (pip install mypy) that reads a file's source and its annotations without executing any of it, then reports where the annotated types don't line up:
$ mypy myscript.py
myscript.py:5: error: Argument 1 to "add" has incompatible type "str"; expected "int"
Found 1 error in 1 file (checked 1 source file)
This is static analysis in the same sense as a linter: it reads code, reasons about it, and reports problems, but nothing in the target file actually runs during the check. That's what makes it fast enough to run on every save or as a required step in CI, catching a whole category of bug before a single test even executes.
The error mypy catches most often: None and Optional
By far the most common real-world mypy error involves a value that can be None being used somewhere that assumes it's always present:
from typing import Optional
def find_user(user_id: int) -> Optional[str]:
return database.get(user_id) # None if not found
name = find_user(42)
print(name.upper()) # mypy error: name might be None
Optional[str] (equivalently str | None in current Python) tells mypy the return value might be absent, and mypy then tracks that possibility forward — calling .upper() on something that might be None is flagged as an error, because None has no .upper() method and this would crash with an AttributeError at runtime if that branch were ever hit. Adding an explicit if name is not None: check narrows the type inside that branch, and mypy recognises the narrowing and stops complaining within it — this is the single most valuable thing static typing catches in ordinary application code, because a missing None-check is one of the most common real production crashes.
Gradual typing: untyped code doesn't have to fail
mypy is explicitly designed for incremental adoption. A function with no annotations at all is treated as accepting and returning Any, which effectively opts it out of checking rather than causing an error:
def legacy_function(x): # no annotations -- mypy mostly leaves this alone
return x * 2
This means an existing, unannotated codebase can add type hints to one module at a time, running mypy against just that module (or the whole project with looser default settings), rather than needing every function in a large codebase annotated before the tool becomes useful at all. This gradual-typing design is what makes adopting a type checker on an established project realistic instead of an all-or-nothing rewrite.
reveal_type(), strict mode, and CI integration
reveal_type(some_value) is a special mypy-only debugging aid: it doesn't exist as a real function, but mypy recognises the call and prints the type it has inferred for the expression at that point, which is useful for understanding why a downstream error is happening. Generic code and structural typing with Protocol are both areas where the inferred type isn't always obvious, and reveal_type() is the fastest way to check assumptions before chasing a confusing error.
mypy --strict turns on a stricter bundle of checks at once — disallowing untyped function definitions, flagging implicit Any, and more — appropriate for a project that wants to commit fully to typing rather than adopt it gradually. Running mypy as a required step in CI, failing the build on any reported error, is what actually makes type hints enforce anything close to a runtime guarantee: without that step, hints remain documentation that nothing stops from silently going stale as code changes around it.