Python Type Hints That Actually Pay Off
Introduction¶
Type hints are optional and ignored at runtime, but a checker (mypy, pyright) uses them to catch a whole class of bugs before you run the code. The trick is knowing where they pay off and not drowning in annotations that add nothing.
Where hints pay off most¶
- Public function signatures — parameters and return types. This is the contract; callers and IDEs rely on it.
- Data models — dataclasses / Pydantic / TypedDict for anything with structure.
- Anything returning
Nonevs a value —Optionalcatches the missing None-check, the single most common Python bug. - Collections with a specific element type —
list[User], notlist.
Where they add little: obvious locals (count = 0), tiny private helpers, test
code.
Modern syntax (3.10+)¶
def find_user(user_id: int, *, active_only: bool = True) -> User | None:
...
def group_by(items: list[T], key: Callable[[T], K]) -> dict[K, list[T]]:
...
names: list[str] = []
config: dict[str, int | str] = {}
X | Noneinstead ofOptional[X];A | Binstead ofUnion[A, B].- Built-in generics:
list[str],dict[str, int],tuple[int, ...]— nofrom typing import List. from __future__ import annotationsmakes all annotations lazy strings (no runtime cost, forward refs just work) — a good default for pre-3.12 code.
Structured data¶
from dataclasses import dataclass
@dataclass(frozen=True, slots=True)
class Server:
host: str
port: int = 22
tags: tuple[str, ...] = ()
# For dict-shaped API payloads:
from typing import TypedDict
class WebhookPayload(TypedDict):
event: str
timestamp: int
data: dict[str, str]
TypedDict types a dict you can't turn into a class (external JSON) without
runtime overhead.
Protocol — structural typing¶
Instead of requiring a specific base class, require a shape:
from typing import Protocol
class Readable(Protocol):
def read(self, n: int = -1) -> bytes: ...
def parse(source: Readable) -> Config:
return _parse(source.read())
Anything with a matching read satisfies Readable — files, sockets, BytesIO —
without inheritance.
CI setup¶
# pyproject.toml
[tool.mypy]
python_version = "3.12"
strict = true
warn_unused_ignores = true
# gradual adoption: relax for legacy packages
[[tool.mypy.overrides]]
module = "legacy.*"
ignore_errors = true
- run: mypy src/ # or: pyright
pyright (used by Pylance) is faster and stricter on inference; mypy has broader plugin support (Django, SQLAlchemy). Pick one, run it in CI, fail the build on new errors.
Gradual adoption on a legacy codebase¶
- Turn on the checker with
ignore_errorsfor everything. - Enable it module by module, newest / most-changed first.
- Type new code fully; require it in review.
- Add hints when you touch a function anyway.
# type: ignore[code]with the specific error code for the few genuine false positives — never a bare# type: ignore.
Don't stop feature work to annotate everything at once; the value is at the edges you're actively changing.
Verification and troubleshooting¶
mypy src/ --show-error-codes
mypy src/ --warn-unused-ignores # find stale ignores after a fix
reveal_type(x) # in code; mypy prints the inferred type, then remove it
error: Function is missing a return type annotationunderstrict— add-> Noneor the real type; strict flags untyped defs.- Checker complains about a third-party import ("missing stubs") — install
the stub package (
types-requests,types-PyYAML) or add it to[[tool.mypy.overrides]]withignore_missing_imports. Optionalerrors everywhere after adding hints — you found real missing None-checks. Add the guards (if x is None: ...), don't blanket-ignore.- Runtime
TypeErrorthe checker didn't catch — hints aren't enforced at runtime; data from JSON/DB/network isAnyuntil validated. Use Pydantic /TypedDict+ a validation step at the boundary. - Slow mypy — enable the cache (on by default in
.mypy_cache), run onsrc/not the venv, use the daemon (dmypy). - Generic function loses the type — you need a
TypeVar(T = TypeVar("T")) or the 3.12def f[T](x: T) -> Tsyntax, notAny.
Related tools and reading¶
- On-site: JSON formatter.
- Related posts: Parsing config files safely, Packaging a Python CLI.
Stuck on something this site can't fix?Reach out to Prabath directly on LinkedIn.More in Python
asyncio for I/O-Bound Scripts
Async Python shines for hundreds of concurrent network calls and nothing else. Here's a correct pattern for a bulk-fetch script, with concurrency limits and error handling.
October 24, 2026Packaging a Python CLI
Turn a script into a `pip install`-able command: the pyproject.toml, the entry point, the src layout, and how users should install it.
October 24, 2026Profiling Python Code
Don't guess at what's slow — measure. cProfile for a script, py-spy for a live process, memray for memory. Here's how to run each and read the output.
October 24, 2026