Server

Python Type Hints That Actually Pay Off

By Prabath Thalangama· October 24, 2026· 3 min read
#python#typing#code-quality

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

  1. Public function signatures — parameters and return types. This is the contract; callers and IDEs rely on it.
  2. Data models — dataclasses / Pydantic / TypedDict for anything with structure.
  3. Anything returning None vs a value — Optional catches the missing None-check, the single most common Python bug.
  4. Collections with a specific element type — list[User], not list.

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 | None instead of Optional[X]; A | B instead of Union[A, B].
  • Built-in generics: list[str], dict[str, int], tuple[int, ...] — no from typing import List.
  • from __future__ import annotations makes 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

  1. Turn on the checker with ignore_errors for everything.
  2. Enable it module by module, newest / most-changed first.
  3. Type new code fully; require it in review.
  4. Add hints when you touch a function anyway.
  5. # 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 annotation under strict — add -> None or 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]] with ignore_missing_imports.
  • Optional errors everywhere after adding hints — you found real missing None-checks. Add the guards (if x is None: ...), don't blanket-ignore.
  • Runtime TypeError the checker didn't catch — hints aren't enforced at runtime; data from JSON/DB/network is Any until validated. Use Pydantic / TypedDict + a validation step at the boundary.
  • Slow mypy — enable the cache (on by default in .mypy_cache), run on src/ not the venv, use the daemon (dmypy).
  • Generic function loses the type — you need a TypeVar (T = TypeVar("T")) or the 3.12 def f[T](x: T) -> T syntax, not Any.
PrabathStuck on something this site can't fix?Reach out to Prabath directly on LinkedIn.