Python’s type hints are optional, which means many codebases have inconsistent coverage - some functions annotated, most not. When coverage is partial, the benefit is also partial.

The value of type hints is not documentation. Documentation can be wrong. The value is the ability to run a static type checker (mypy or pyright) and have it find bugs before they reach production.

But not all type hints are equally useful. Some annotations describe things that are obvious. Others describe things that are genuinely dangerous - places where a wrong assumption leads to a runtime error. Here are the ones that do the most work.

Optional: The Most Commonly Missing Annotation

The single most bug-causing mistake in Python is passing None where a value is expected. This is so common in every language that it has a name: the billion-dollar mistake.

# This is wrong - says name is str, but it can be None
def greet(name: str) -> str:
    return f"Hello, {name.upper()}"  # AttributeError if name is None

# This is correct
from typing import Optional

def greet(name: Optional[str]) -> str:
    if name is None:
        return "Hello, stranger"
    return f"Hello, {name.upper()}"

Optional[str] is equivalent to Union[str, None]. In Python 3.10+, you can write str | None instead.

The pattern: every function argument that could be None should be annotated as Optional. Every return type that could return None should be annotated as Optional. mypy will then force you to handle the None case everywhere it is used.

# Python 3.10+ syntax
def find_user(user_id: int) -> User | None:
    ...

# After calling it, mypy forces you to handle None
user = find_user(42)
user.name  # mypy error: Item "None" of "User | None" has no attribute "name"

if user is not None:
    user.name  # mypy is happy: user is narrowed to User in this branch

TypedDict for Dictionary Structures

Dictionaries with known keys and typed values are extremely common in Python. Without TypedDict, the type is dict[str, Any] - meaning no checking at all.

from typing import TypedDict

class UserConfig(TypedDict):
    name: str
    email: str
    age: int
    is_admin: bool

def process_user(user: UserConfig) -> None:
    print(user["name"].upper())
    print(user["age"] + 1)
    print(user["unknown_key"])  # mypy error: TypedDict "UserConfig" has no key "unknown_key"

TypedDict lets you annotate dictionaries so that key access is type-checked. Accessing a key that does not exist, or using a value as the wrong type, is caught statically.

For API responses, configuration objects, and any other dict-shaped data structure, TypedDict is the right annotation.

Literal Types for Constrained Values

When a parameter can only be specific values, Literal encodes that constraint:

from typing import Literal

def set_status(status: Literal["active", "inactive", "pending"]) -> None:
    ...

set_status("active")   # fine
set_status("deleted")  # mypy error: Argument 1 to "set_status" has incompatible type "Literal['deleted']"

This is more informative than str and better than an enum for simple cases. The type checker will catch invalid values at the call site rather than at runtime.

Literal types also enable exhaustive checking - making sure you handle all possible values:

def handle_status(status: Literal["active", "inactive", "pending"]) -> str:
    if status == "active":
        return "green"
    elif status == "inactive":
        return "red"
    elif status == "pending":
        return "yellow"
    else:
        # mypy knows this is unreachable if all Literal values are handled
        reveal_type(status)  # Literal[Never] - all cases handled

Protocol for Structural Typing

Protocol lets you define duck-typing interfaces that are checked statically. Instead of requiring inheritance from a specific base class, you require that an object has specific methods or attributes.

from typing import Protocol

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

class Readable(Protocol):
    def read(self, size: int = -1) -> bytes: ...

def process_resource(resource: Closeable) -> None:
    try:
        do_work(resource)
    finally:
        resource.close()  # Safe because Closeable guarantees this method exists

Any class with a close() method satisfies the Closeable protocol without needing to explicitly inherit from it. This is Python’s approach to the Go interface pattern.

Protocol is particularly useful for:

  • Function parameters that should accept multiple concrete types sharing behavior
  • Testing with mock objects that implement the minimal interface
  • Library code that should work with user-defined types

Generic Types for Reusable Code

When a function or class works with any type but preserves type information, use generics:

from typing import TypeVar, Generic

T = TypeVar('T')

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

# mypy knows the return type based on the input type
numbers: list[int] = [1, 2, 3]
result = first_item(numbers)  # result is int | None, not Any | None

Without TypeVar, the function signature would be list[Any] -> Any, and the type information is lost. With TypeVar, mypy tracks that if you pass a list[int], you get int | None back.

ParamSpec for Decorator Typing

Decorators are notoriously hard to type correctly. ParamSpec preserves the parameter types of wrapped functions:

from typing import Callable, TypeVar
from typing import ParamSpec
import functools

P = ParamSpec('P')
R = TypeVar('R')

def log_calls(func: Callable[P, R]) -> Callable[P, R]:
    @functools.wraps(func)
    def wrapper(*args: P.args, **kwargs: P.kwargs) -> R:
        print(f"Calling {func.__name__}")
        return func(*args, **kwargs)
    return wrapper

@log_calls
def add(x: int, y: int) -> int:
    return x + y

add(1, 2)      # fine
add("a", "b")  # mypy error: argument types are preserved through the decorator

Before ParamSpec, decorators typically lost all type information about the decorated function’s parameters. Now the type checker can verify that decorated functions are called correctly.

The Tools That Make Type Hints Work

Type hints are annotations. They do nothing at runtime without a type checker.

mypy is the original and most widely used. It is slower but thorough. Recommended for CI.

pyright (also available as pylance in VS Code) is faster and more strict. It is the right choice for editor-integrated type checking.

Configuration that matters:

# mypy.ini
[mypy]
python_version = 3.12
strict = true
warn_return_any = true
warn_unused_configs = true

Running with strict = true is harsh on legacy code but correct for new code. It enables all optional checks, including requiring function argument and return type annotations.

The Incremental Adoption Path

Phase What to annotate Benefit
1 Return types on public functions Most function outputs are type-checked
2 All function parameters Call sites are type-checked
3 Optional on nullable values None-safety enforced
4 TypedDict for dict structures Dict key access checked
5 Strict mode, no Any Full type safety

Starting with phases 1-3 on new code provides most of the bug-prevention benefit without requiring a full codebase migration.

Bottom Line

Python type hints prevent real bugs when combined with a type checker running in CI. The most impactful annotations are Optional (or | None) for nullable values, TypedDict for dictionary structures, Literal for constrained values, and Protocol for structural typing. Mypy and pyright find categories of bugs - null pointer equivalents, wrong argument types, missing dictionary keys - that unit tests often miss because they test the happy path. Type hints on a new codebase with strict mode from the start is significantly easier than adding them to an existing untyped codebase.