Python Type Hints and Mypy: Gradually Typing a Large Codebase

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 PyPI
  • typeshed — bundled with Mypy, covers the stdlib and major packages
  • For libraries without stubs, ignore_missing_imports = true in 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.