Type Hints Don't Make Python Into Java
Python's type hint system, introduced in PEP 484, is deliberately optional. The runtime ignores annotations completely — x: int = "hello" runs without error. Type checking happens through external tools like Mypy, Pyright, or Pyre.
This optionality is actually the system's greatest strength for existing codebases. You don't need to type everything at once. You can add hints incrementally, starting where they provide the most value, and gradually increase coverage over months or years.
I've led type hint adoption on two large Python codebases (200k+ lines each). Here's what worked.
Starting Point: Where to Add Types First
Don't start by typing your entire codebase. Start where types catch the most bugs:
- Public API boundaries — function signatures of modules that other teams import. These are where misunderstandings happen most.
- Data models — dataclasses, Pydantic models, TypedDicts. If your data shapes are documented with types, half your bugs disappear.
- Functions that have had bugs — if a function has been fixed twice because someone passed the wrong argument type, add types to prevent a third time.
Start with function signatures only. Don't worry about local variable annotations — Mypy can usually infer those.
# Before: what does this return? Can user_id be None? What's in the dict?
def get_user_settings(user_id, include_defaults):
...
# After: clear contract
def get_user_settings(
user_id: int,
include_defaults: bool = True,
) -> dict[str, str | int | bool]:
...
Mypy Configuration for Gradual Adoption
The default Mypy config is too strict for an existing codebase. You'll get thousands of errors and give up. Start permissive and tighten over time.
# mypy.ini
[mypy]
python_version = 3.11
warn_return_any = true
warn_unused_configs = true
check_untyped_defs = false # start with false, enable later
disallow_untyped_defs = false # same
ignore_missing_imports = true # third-party libs without stubs
# Strict mode for new modules
[mypy-myapp.api.*]
disallow_untyped_defs = true
check_untyped_defs = true
# Still permissive for legacy code
[mypy-myapp.legacy.*]
ignore_errors = true
The per-module overrides are the key. New code gets strict checking from day one. Legacy modules get a pass until someone has time to annotate them. You gradually move modules from permissive to strict.
Common Type Patterns
Optional and Union
from typing import Optional
# These are equivalent in Python 3.10+
def find_user(email: str) -> Optional[User]: # older style
def find_user(email: str) -> User | None: # modern style (3.10+)
...
I'd argue X | None is clearer than Optional[X] once your team is on 3.10+. The word "Optional" misleadingly suggests the parameter itself is optional, when it really means the return value might be None.
TypedDict for Unstructured Data
from typing import TypedDict
class UserResponse(TypedDict):
id: int
name: str
email: str
is_active: bool
class UserResponsePartial(TypedDict, total=False):
id: int
name: str
email: str
is_active: bool
def serialize_user(user: User) -> UserResponse:
return {
"id": user.id,
"name": user.name,
"email": user.email,
"is_active": user.is_active,
}
TypedDict is perfect for JSON response bodies, configuration dictionaries, and any place you're using plain dicts with known keys. total=False makes all keys optional — useful for partial update payloads.
Generics and TypeVar
from typing import TypeVar, Sequence
T = TypeVar("T")
def first_or_none(items: Sequence[T]) -> T | None:
return items[0] if items else None
# Mypy knows this returns str | None
result = first_or_none(["a", "b", "c"])
# And this returns int | None
result = first_or_none([1, 2, 3])
Protocol for Structural Typing
from typing import Protocol
class Renderable(Protocol):
def render(self) -> str: ...
def display(item: Renderable) -> None:
print(item.render())
# Any class with a render() -> str method works. No inheritance needed.
class MarkdownDoc:
def render(self) -> str:
return "# Hello"
display(MarkdownDoc()) # type-checks fine
Protocol is Python's answer to Go interfaces. It checks structural compatibility — if the object has the right methods with the right signatures, it satisfies the Protocol. No explicit class Foo(Renderable): inheritance needed.
Handling Third-Party Libraries
Many popular libraries ship type stubs now. For those that don't:
types-requests,types-redis,types-PyYAML— community-maintained stub packages on PyPItypeshed— bundled with Mypy, covers the stdlib and major packages- For libraries without stubs,
ignore_missing_imports = truein your mypy.ini for that module
If you're calling a popular untyped library extensively, writing a thin typed wrapper is sometimes worth it:
# typed_redis.py — thin wrapper with type annotations
import redis as _redis
class TypedRedis:
def __init__(self, client: _redis.Redis) -> None:
self._client = client
def get(self, key: str) -> str | None:
val = self._client.get(key)
return val.decode() if val else None
def set(self, key: str, value: str, ex: int | None = None) -> bool:
return bool(self._client.set(key, value, ex=ex))
Running Mypy in CI
Add Mypy to your CI pipeline with the same configuration as local development. Fail the build on type errors in strictly-typed modules:
# In your CI config
mypy myapp/ --config-file mypy.ini --no-error-summary
# Exit code 0 = clean, 1 = errors found
For the gradual rollout, track coverage with mypy --txt-report to see which modules are typed and which aren't. Set quarterly targets: "Q1: core modules at 80% coverage. Q2: API layer at 90%."
The realistic timeline for a 200k-line codebase: 6-12 months to get critical paths typed, 18-24 months for comprehensive coverage. Don't try to rush it — gradual adoption with consistent progress beats a "type everything this sprint" mandate that burns people out.