Overview
I put off adding type hints to my Python projects for years because the examples in the docs always looked like they were solving problems I didn't have. Then I inherited a 40,000-line codebase with no hints, no tests on half of it, and a bug that turned out to be a type confusion between two dicts with similar shapes. Adding hints to the critical paths would have caught it in a second.
This is what I'd tell someone adding types to an existing codebase, based on doing it three times now.
Why bother
| Benefit | When it shows up |
|---|---|
| Catch bugs before running tests | Immediately on any nontrivial function |
| Better IDE autocomplete | The moment you add the first annotation |
| Self-documenting signatures | When reading unfamiliar code |
| Safe refactoring | Renaming or changing signatures across files |
| Faster code review | Reviewers stop asking "what type is this?" |
The IDE benefit alone justifies the effort. Autocomplete on a typed dict is dramatically better than on an untyped one, and the difference compounds across a full day of coding.
The basics you'll use constantly
def greet(name: str) -> str:
return f"Hello, {name}"
def process(items: list[str], counts: dict[str, int]) -> None:
for item in items:
counts[item] = counts.get(item, 0) + 1
Python 3.9+ has built-in generics for the standard collections. Before that, you had to import from typing:
# Python 3.8 and earlier
from typing import List, Dict, Optional
def process(items: List[str], counts: Dict[str, int]) -> None:
...
If you're on 3.9+, use the lowercase versions. They're the same thing and less typing.
Optional and None
# Python 3.10+
def find_user(user_id: int) -> User | None:
...
# Python 3.9 and earlier
from typing import Optional
def find_user(user_id: int) -> Optional[User]:
...
Optional[X] means "X or None." It does not mean "an optional argument." That naming has confused people since it was introduced, and the X | None syntax is much clearer.
For arguments with defaults, None is implied:
def connect(host: str, port: int = 5432, timeout: float | None = None):
...
Dicts with known keys: TypedDict
This is the feature that would have caught the bug I mentioned above. When you have a dict with a fixed set of keys and value types, TypedDict tells the type checker what to expect.
from typing import TypedDict
class UserDict(TypedDict):
id: int
name: str
email: str
def process_user(user: UserDict) -> str:
return user["name"].upper()
# mypy catches this
process_user({"id": 1, "name": "alice"}) # missing 'email'
process_user({"id": "1", "name": "alice", "email": "a@b.com"}) # id is str
Unlike dict[str, str], TypedDict knows which keys are required and what type each one holds. It's the single most useful type construct for code that passes around JSON-shaped dicts.
For optional keys, use total=False or the NotRequired marker:
class Config(TypedDict):
host: str
port: int
debug: NotRequired[bool]
Literal types
from typing import Literal
def set_log_level(level: Literal["debug", "info", "warning", "error"]) -> None:
...
set_log_level("info") # ok
set_log_level("verbose") # mypy error
Anywhere you'd otherwise accept a string and validate it at runtime, a Literal makes the constraint visible to the type checker. I use this for function arguments that accept a fixed set of strings — it turns runtime bugs into compile-time errors.
Running mypy
pip install mypy
mypy src/
For an existing codebase, running it on everything at once produces a wall of errors. Don't do that. Two approaches:
Gradual adoption
mypy --ignore-missing-imports --no-error-summary src/
Start by fixing only the errors in files you're actively working on. Add type hints as you touch functions. Over a few months, the typed portion grows naturally.
Strict mode for new code
[mypy]
strict = true
exclude = [
"^legacy/",
"^migrations/",
]
Strict mode on new code, nothing enforced on old code. Every new module gets full checking; the legacy directory is excluded. This avoids the "fix 4,000 errors before you can use the tool" problem.
The settings that matter
[mypy]
python_version = 3.12
warn_return_any = true
warn_unused_configs = true
disallow_untyped_defs = true
disallow_incomplete_defs = true
check_untyped_defs = true
no_implicit_optional = true
strict_equality = true
warn_redundant_casts = true
warn_unused_ignores = true
[[mypy.overrides]]
module = ["requests.*", "yaml.*"]
ignore_missing_imports = true
| Setting | What it catches |
|---|---|
disallow_untyped_defs |
Functions with no annotations at all |
check_untyped_defs |
Still checks functions without annotations |
warn_return_any |
Functions that return Any silently |
no_implicit_optional |
Arguments defaulting to None without | None |
warn_unused_ignores |
# type: ignore comments that no longer do anything |
warn_unused_ignores is underrated. When you suppress a type error with # type: ignore, and later upgrade the library or fix the underlying issue, the suppression becomes dead code that hides future errors. This flag tells you when that happens.
Handling third-party libraries
Some libraries ship type stubs. Some have stubs maintained separately. Some have neither.
# Library ships types — just works
import requests
# Library has no types — install stubs
pip install types-PyYAML types-requests
# Neither exists — you write a stub or ignore
mypy --ignore-missing-imports
The typeshed repository maintains stubs for a huge number of libraries. Search for types-{library} on PyPI before writing your own.
For libraries you can't stub, a targeted ignore in the config is better than a global one:
[[mypy.overrides]]
module = ["some_untyped_lib.*"]
ignore_missing_imports = true
Union types and narrowing
def process(value: int | str) -> str:
if isinstance(value, int):
return str(value * 2)
return value.upper() # mypy knows this is a str
mypy narrows the type inside the conditional. This works with isinstance, is None, in checks, and custom type guards.
from typing import TypeGuard
def is_string_list(value: list[object]) -> TypeGuard[list[str]]:
return all(isinstance(x, str) for x in value)
def process(items: list[object]) -> None:
if is_string_list(items):
# mypy knows items is list[str] here
for item in items:
print(item.upper())
TypeGuard is the explicit version of the narrowing that isinstance gives you for free. It's how you teach mypy about custom validation functions.
What people complain about
| Complaint | Reality |
|---|---|
| "It's too verbose" | True for complex generics, fine for 90% of code |
| "It slows down development" | Slower to write, faster to debug — net positive after a week |
| "Dynamic Python doesn't need types" | You can ignore mypy entirely if you want; the annotations still improve your editor |
| "False positives are everywhere" | Less true than it was five years ago, but yes for certain libraries |
| "The stdlib types are wrong" | Occasionally, and you can override them |
The false positive one is worth taking seriously. mypy is sometimes wrong, especially with complex generics or libraries with incomplete stubs. The correct response is a targeted # type: ignore with a comment explaining why, not disabling mypy for the file.
# type: ignore[arg-type] # library stub is wrong; actual type is compatible
result = third_party_func(obj)
The bracket syntax restricts the ignore to a specific error code, which prevents it from hiding unrelated problems.
What I'd do on a new project
- Install mypy and Ruff (Ruff has type-aware linting rules too).
- Start with strict mode enabled in
pyproject.toml. - Add
mypyto CI from the first commit. - Annotate every function signature. Return types especially.
- Use TypedDict for JSON-shaped data. Use dataclasses or Pydantic for internal data.
- Add types incrementally to the annotations as you go —
listanddictare fine to start;list[str]can come later.
The strict-from-day-one approach avoids the migration problem entirely. On an existing project, the strict-on-new-code approach gets you most of the benefit without the migration cost.
