Skip to content
C Codeloom
Python

Python Typing Hints Complete Guide

A practical guide to Python type hints: when they help, what mypy actually checks, generics, protocols, and how to roll out typing in a codebase without slowing the team down.

·5 min read · By Codeloom
Intermediate 11 min read

What you'll learn

  • Why type hints matter even though Python ignores them
  • Generics with TypeVar, ParamSpec, and Self
  • Protocols vs ABCs for duck typing
  • Configuring mypy or pyright in a real project
  • How to add typing to a legacy codebase incrementally

Prerequisites

  • Basic Python

What and why

Python type hints are annotations that describe what arguments and return values should look like. The interpreter ignores them at runtime; the value comes from static analyzers (mypy, pyright, pyre) that read the hints and flag mismatches before code runs. The IDE uses the same hints for autocomplete and refactor safety.

You want type hints for three reasons: catch bugs earlier, document intent in code rather than docstrings, and make refactors safer. The cost is some upfront ceremony and the occasional fight with the checker.

Mental model

Think of types as a second program that runs only in the checker. Your runtime program does the work; the type program proves that the work is consistent. They share syntax but never share execution.

Source file with annotations
      |
      +----> Python interpreter (ignores types, runs code)
      |
      +----> mypy / pyright    (only reads types, no runtime)
                  |
                  v
            Type errors at edit time

def add(a: int, b: int) -> int:
  return a + b

add("x", 1)   # runtime: TypeError on '+'
            # mypy:    error before you run it
Two parallel programs sharing one source

Hands-on example

Start with primitives and containers. Built-in generics (PEP 585) let you use list[int] instead of List[int] in Python 3.9+.

def total(prices: list[float]) -> float:
    return sum(prices)

def index_users(users: dict[str, int]) -> set[str]:
    return {name for name, age in users.items() if age >= 18}

Optional[X] is X | None. Prefer the pipe syntax in modern code.

def find_user(uid: str) -> User | None:
    ...

Generics let you write functions that work for many types while preserving the relationship between inputs and outputs.

from typing import TypeVar

T = TypeVar("T")

def first(items: list[T]) -> T | None:
    return items[0] if items else None

x: int | None = first([1, 2, 3])    # checker knows x is int | None

Protocols are the typing equivalent of duck typing. Any object with the right shape is accepted; you do not need to inherit.

from typing import Protocol

class SupportsClose(Protocol):
    def close(self) -> None: ...

def safe_close(resource: SupportsClose) -> None:
    resource.close()

TypedDict describes dict shapes, useful for JSON payloads:

from typing import TypedDict

class Order(TypedDict):
    id: str
    total: float
    items: list[str]

def total_of(order: Order) -> float:
    return order["total"]

For decorators, ParamSpec preserves parameter signatures, and Self (3.11+) lets methods return their own subclass cleanly.

from typing import Self

class Builder:
    def with_name(self, name: str) -> Self:
        self.name = name
        return self

Common pitfalls

Any is contagious. The moment you annotate something Any, all downstream checks vanish. Use it as a deliberate escape hatch, not a default.

Mutable default arguments cannot be annotated away. def f(x: list[int] = []) -> ... still shares one list across calls. Type checkers will not save you.

Forward references trip people up. If you reference a class defined later in the same file, quote the name ("User") or use from __future__ import annotations to defer evaluation.

from __future__ import annotations

class Node:
    def add_child(self, child: Node) -> None: ...

Be careful with List[T] invariance. list[Dog] is not a list[Animal] even if Dog extends Animal, because the type system worries you might append(Cat). Use Sequence[Animal] for read-only positions.

Strict optional handling matters. x.upper() on a str | None fails the checker. Narrow with if x is None: return or assert x is not None.

Production tips

Pick one checker per repo. Mypy is the de facto standard; pyright is faster and runs inside VS Code’s Pylance. Mixing both creates contradictory error reports.

Configure incrementally. Start with mypy --strict on one new module, not the whole codebase. Use per-module overrides in pyproject.toml:

[tool.mypy]
python_version = "3.11"
strict = false

[[tool.mypy.overrides]]
module = "myapp.payments.*"
strict = true

Type your public API first. Library boundaries, request models, and serialization layers benefit most. Inner helpers can wait.

Use from __future__ import annotations everywhere. It makes annotations strings, eliminates forward-reference headaches, and removes runtime cost. The only catch is libraries that read annotations at runtime (Pydantic v1, dataclasses with default_factory referencing types); check before flipping it on.

Run the checker in CI. Without enforcement, types drift. A single GitHub Actions step that fails on new errors keeps the codebase honest.

Avoid stringly typed APIs. If a function takes status: str with three valid values, use Literal["pending", "shipped", "cancelled"] or an Enum. The checker will then catch typos.

Wrap-up

Type hints are a static check layered on top of Python. They make refactors safer, document intent inline, and surface bugs before runtime. Use built-in generics, prefer | over Optional, reach for Protocol for duck typing, and treat Any as a last resort. Roll out incrementally, configure per-module strictness, and enforce in CI. The first week is friction; after that, you stop wanting to write code without it.