← All articles

Python Type Hints in Practice: mypy, Protocols, and TypeVar

January 15, 2026 · 6 min read

pythontesting

Python's type system has matured significantly. Type hints are no longer just documentation — with mypy, pyright, or Pylance, they catch real bugs before runtime. But most codebases use only basic annotations. Protocols, TypeVar, ParamSpec, and the newer typing features unlock structural typing and generic programming that makes Python code both safer and more expressive.

Basic Annotations

Start with the fundamentals:

from typing import Optional, Union
from collections.abc import Sequence
 
# Built-in generics (Python 3.10+, use typing.List etc for older)
def process_items(items: list[str]) -> dict[str, int]:
    return {item: len(item) for item in items}
 
# Optional (same as T | None in 3.10+)
def find_user(user_id: int) -> Optional[dict]:
    ...
 
# Union types
def parse_value(value: str | int | float) -> float:
    return float(value)
 
# Callable
from collections.abc import Callable
 
def apply_transform(
    data: list[int],
    transform: Callable[[int], int]
) -> list[int]:
    return [transform(x) for x in data]
 
# Sequences (more general than list)
def sum_values(values: Sequence[float]) -> float:
    return sum(values)

TypeVar for Generic Functions

TypeVar lets you write functions that work with multiple types while preserving type information:

from typing import TypeVar
 
T = TypeVar("T")
 
def first(items: list[T]) -> T | None:
    return items[0] if items else None
 
result = first([1, 2, 3])  # mypy knows this is int | None
name = first(["alice", "bob"])  # mypy knows this is str | None

Bound TypeVars constrain to subtypes:

from typing import TypeVar
 
class SupportsLessThan:
    def __lt__(self, other) -> bool: ...
 
Comparable = TypeVar("Comparable", bound=SupportsLessThan)
 
def maximum(items: list[Comparable]) -> Comparable:
    return max(items)

For class hierarchies:

from typing import TypeVar
from myapp.models import BaseModel
 
ModelT = TypeVar("ModelT", bound=BaseModel)
 
class Repository[ModelT]:
    def __init__(self, model_class: type[ModelT]):
        self.model_class = model_class
 
    async def get_by_id(self, id: int) -> ModelT | None:
        ...
 
    async def create(self, **kwargs) -> ModelT:
        ...

Protocols: Structural Typing

Protocols define interfaces through structure, not inheritance. If an object has the required methods, it satisfies the Protocol — no explicit inheritance needed:

from typing import Protocol, runtime_checkable
 
@runtime_checkable  # Enables isinstance() checks
class Serializable(Protocol):
    def to_dict(self) -> dict: ...
    def to_json(self) -> str: ...
 
class Article:
    def __init__(self, title: str, content: str):
        self.title = title
        self.content = content
 
    def to_dict(self) -> dict:
        return {"title": self.title, "content": self.content}
 
    def to_json(self) -> str:
        import json
        return json.dumps(self.to_dict())
 
def export(obj: Serializable) -> bytes:
    return obj.to_json().encode()
 
# Article satisfies Serializable without inheriting from it
export(Article("Test", "Content"))  # ✓ mypy is happy

Protocols are ideal for dependency injection:

from typing import Protocol
 
class EmailSender(Protocol):
    async def send(self, to: str, subject: str, body: str) -> None: ...
 
class ArticleService:
    def __init__(self, email_sender: EmailSender):
        self._email = email_sender
 
    async def publish(self, article) -> None:
        article.published = True
        await self._email.send(
            to=article.author.email,
            subject="Your article was published",
            body=f"'{article.title}' is now live."
        )
 
# Both work — no common base class needed
class SmtpEmailSender:
    async def send(self, to: str, subject: str, body: str) -> None: ...
 
class SendGridEmailSender:
    async def send(self, to: str, subject: str, body: str) -> None: ...
 
class MockEmailSender:  # For tests
    sent_emails: list[dict] = []
    async def send(self, to: str, subject: str, body: str) -> None:
        self.sent_emails.append({"to": to, "subject": subject, "body": body})

ParamSpec: Preserving Decorator Signatures

ParamSpec preserves the parameter types of wrapped functions in decorators:

from typing import TypeVar, Callable
from typing import ParamSpec
import functools
import time
 
P = ParamSpec("P")
R = TypeVar("R")
 
def timing(func: Callable[P, R]) -> Callable[P, R]:
    @functools.wraps(func)
    def wrapper(*args: P.args, **kwargs: P.kwargs) -> R:
        start = time.perf_counter()
        result = func(*args, **kwargs)
        elapsed = time.perf_counter() - start
        print(f"{func.__name__} took {elapsed:.3f}s")
        return result
    return wrapper
 
@timing
def process_data(items: list[int], multiplier: float = 1.0) -> list[float]:
    return [x * multiplier for x in items]
 
# mypy knows process_data still takes (list[int], multiplier=float)
result = process_data([1, 2, 3], multiplier=2.5)  # ✓
result = process_data("wrong")  # ✗ mypy catches this

Without ParamSpec, decorators would lose parameter type information and mypy would accept any arguments.

Literal and TypedDict

Literal restricts values to specific constants:

from typing import Literal
 
LogLevel = Literal["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"]
HttpMethod = Literal["GET", "POST", "PUT", "PATCH", "DELETE"]
 
def log(level: LogLevel, message: str) -> None:
    print(f"[{level}] {message}")
 
log("INFO", "Server started")    # ✓
log("VERBOSE", "Too detailed")   # ✗ mypy error

TypedDict types dictionaries with known keys:

from typing import TypedDict, NotRequired
 
class ArticleDict(TypedDict):
    id: int
    title: str
    content: str
    published: bool
    tags: list[str]
    author_email: NotRequired[str]  # Optional key
 
def render_article(article: ArticleDict) -> str:
    return f"<h1>{article['title']}</h1><p>{article['content']}</p>"
 
# mypy catches missing required keys
render_article({"id": 1, "title": "Test"})  # ✗ missing required fields

Overload for Multiple Return Types

@overload defines multiple signatures for a single function:

from typing import overload
 
@overload
def parse_id(value: str) -> int: ...
@overload
def parse_id(value: int) -> int: ...
@overload
def parse_id(value: None) -> None: ...
 
def parse_id(value: str | int | None) -> int | None:
    if value is None:
        return None
    return int(value)
 
result1 = parse_id("42")   # mypy knows: int
result2 = parse_id(42)     # mypy knows: int
result3 = parse_id(None)   # mypy knows: None

mypy Configuration

# mypy.ini or pyproject.toml [tool.mypy]
[mypy]
python_version = 3.12
strict = true  # Enables all strict checks
 
# Per-module overrides for third-party stubs
[mypy-sqlalchemy.*]
ignore_missing_imports = true
 
[mypy-factory.*]
ignore_missing_imports = true

Strict mode enables:

  • disallow_untyped_defs: All functions must have annotations
  • disallow_any_generics: No bare list, dict without parameters
  • warn_return_any: Warn when returning Any from typed functions
  • warn_unused_ignores: Catch unnecessary # type: ignore comments

Gradual adoption with --ignore-missing-imports:

# Start by checking just specific directories
mypy myapp/services/ myapp/repositories/ --ignore-missing-imports
 
# Run in CI
mypy . --strict 2>&1 | tee mypy-report.txt

Common Patterns and Pitfalls

cast() for unavoidable type narrowing:

from typing import cast
 
def get_user_id(data: dict) -> int:
    # mypy can't prove data["id"] is int without runtime check
    return cast(int, data["id"])

TYPE_CHECKING for circular imports:

from __future__ import annotations  # Makes all annotations strings lazily evaluated
from typing import TYPE_CHECKING
 
if TYPE_CHECKING:
    from myapp.models import Article  # Only imported when type-checking, not at runtime
 
def process(article: Article) -> None:  # Works because of __future__ annotations
    ...

reveal_type() for debugging:

values = [1, 2, 3]
reveal_type(values)  # mypy prints: Revealed type is "builtins.list[builtins.int]"

Remove reveal_type() calls before committing — they're development-only.

Types catch bugs that tests miss because they operate on code paths, not just execution paths. The upfront investment in typing pays back in IDE autocomplete, refactoring safety, and bugs caught before code ever runs.