Type Hints in Python (typing module)

The earlier docstrings and annotations article covered basic hints like param: int. This article goes considerably deeper into the python typing module itself — unions, generics, structural typing with Protocol, and shaped dictionaries with TypedDict — the territory where most "type hints 101" articles stop short.

From Basic Annotations to the typing Module's Real Power

A quick recap

Simple annotations — param: int, -> str — cover the basic cases well, as covered in the earlier article. The typing module unlocks considerably more precise expressiveness: unions of several possible types, generics that preserve type relationships across a function's inputs and outputs, structural interfaces, and precisely shaped dictionaries.

The evolution worth knowing

A handful of PEPs shaped typing into what it is today: PEP 484 introduced type hints themselves. PEP 526 added syntax for variable annotations outside function signatures. PEP 585 (Python 3.9) brought built-in generics — list[int] directly, rather than requiring List[int] imported from typing. PEP 604 (Python 3.10) introduced the | union shorthand. Through all of this evolution, one thing hasn't changed: these remain just annotations — Python itself never enforces any of them at runtime, exactly as covered in the earlier article. Everything in this article is about writing more precise, more useful hints for readers and static type checkers — not about adding runtime validation, which Section 5 addresses separately.

What this article covers

Modern union and optional syntax, generics with TypeVar, Protocol for structural typing, TypedDict for precisely shaped dictionaries, and finally, running Mypy for real and where runtime validation (Pydantic) actually fits in.

Modern Union and Optional Syntax

Optional[X] vs. X | None
from typing import Optional

def find_user(user_id: int) -> Optional[dict]:
    ...
# Modern equivalent (Python 3.10+)
def find_user(user_id: int) -> dict | None:
    ...

Both mean exactly the same thing: this function returns either a dict, or None. The | syntax is now the recommended default, unless your project specifically needs to support Python versions older than 3.10, in which case Optional[X] (or a from __future__ import annotations workaround) remains necessary.

Union[X, Y] vs. X | Y
from typing import Union

def process(value: Union[int, str]) -> str:
    ...
# Modern equivalent
def process(value: int | str) -> str:
    ...

Same relationship: X | Y is the modern, preferred shorthand for "this could be either type," replacing Union[X, Y] from typing for anything targeting Python 3.10 and later.

Built-in generics no longer require imports

Since Python 3.9, list[int], dict[str, int], and similar work directly as generic type hints, without importing List/Dict from typing at all:

# Modern (3.9+)
def get_names() -> list[str]:
    ...

def get_scores() -> dict[str, int]:
    ...
# Older, still-valid equivalent, needed for pre-3.9 support
from typing import List, Dict

def get_names() -> List[str]:
    ...
A practical example combining both
def normalize_id(user_id: int | str) -> str:
    """Accepts either an int or str ID and normalizes it to a string."""
    return str(user_id)

print(normalize_id(42))       # "42"
print(normalize_id("42"))     # "42"

The int | str annotation communicates precisely, at a glance, that this function genuinely accepts either type — a static type checker (covered in Section 5) would flag a call like normalize_id([1, 2]) as an error, even though Python itself would still run it without complaint.

Generics with TypeVar and Generic

The problem generics solve

Consider a general-purpose function meant to work with any list:

def first(items: list) -> object:
    return items[0]

result = first([1, 2, 3])
# result is typed as 'object' — the specific fact that it's actually an int is lost!

Annotating the return type as plain object is technically valid, but it throws away genuinely useful information — a static type checker has no way of knowing that calling first() on a list[int] actually returns an int, specifically, rather than some arbitrary, unknown object. Generics solve exactly this: writing a function or class once, but preserving the specific, consistent type relationship between its inputs and outputs.

The basic pattern with TypeVar
from typing import TypeVar

T = TypeVar("T")

def first(items: list[T]) -> T:
    return items[0]

result = first([1, 2, 3])
# a type checker now correctly infers result is an int, not just 'object'

result2 = first(["a", "b", "c"])
# and here, correctly infers result2 is a str

T is a placeholder — a type variable — that gets "filled in" consistently for a given call. Whatever specific type T resolves to for items (here, int or str), the return type is understood to be that exact same type, preserving the connection between input and output that plain object would have lost entirely.

A generic class with Generic[T]
from typing import Generic, TypeVar

T = TypeVar("T")

class Stack(Generic[T]):
    def __init__(self) -> None:
        self.items: list[T] = []

    def push(self, item: T) -> None:
        self.items.append(item)

    def pop(self) -> T:
        return self.items.pop()

int_stack: Stack[int] = Stack()
int_stack.push(1)
int_stack.push(2)
# a type checker knows int_stack.pop() returns an int, specifically

Stack(Generic[T]) declares that Stack is generic over some type T — and Stack[int] specifically instantiates that generic with T bound to int for this particular instance, letting a type checker correctly track that every push() and pop() on int_stack deals specifically with int values, not some generic, unspecified type.

A brief note on variance

There's a more advanced nuance worth knowing exists, without diving deep into it here: variance — specifically covariance and contravariance — governs how generic types relate to each other when subtyping is involved (for instance, whether a Stack[Dog] should be considered compatible with something expecting a Stack[Animal], given that Dog is a subclass of Animal). This becomes genuinely relevant particularly around Callable type hints and container subtyping, and it's the kind of thing that tends to surface as a confusing type-checker error the first time you hit it. For now, it's enough to know the term exists and roughly what it concerns — the typing module's documentation covers it in detail once you actually encounter a variance-related error in practice.

Protocol for Structural Typing, and TypedDict for Shaped Dictionaries

Protocol: structural typing without inheritance

As introduced in the earlier polymorphism and abstraction articles, typing.Protocol lets a class satisfy an interface simply by having the required methods — no explicit inheritance required at all. This is structural typing ("if it has the right shape, it satisfies the contract"), contrasted with the nominal typing ABCs require (a class must explicitly inherit from the ABC to count as satisfying it).

from typing import Protocol

class Drawable(Protocol):
    def draw(self) -> str:
        ...

class Circle:
    def draw(self) -> str:
        return "Drawing a circle"

class Rectangle:
    def draw(self) -> str:
        return "Drawing a rectangle"

def render(shape: Drawable) -> None:
    print(shape.draw())

render(Circle())      # works — Circle has a matching draw() method
render(Rectangle())    # works too — same reason

Neither Circle nor Rectangle inherits from Drawable in any way — a static type checker recognizes both as satisfying the Drawable protocol purely because each happens to have a matching draw() -> str method. This is genuinely valuable when defining an interface against classes you don't own or can't modify to add inheritance to.

@runtime_checkable for isinstance() support

By default, Protocol classes can't be used with isinstance() at runtime — they're purely a static-analysis construct. @runtime_checkable changes that:

from typing import Protocol, runtime_checkable

@runtime_checkable
class Drawable(Protocol):
    def draw(self) -> str:
        ...

print(isinstance(Circle(), Drawable))   # True — now works at runtime too

Worth a caveat: @runtime_checkable's isinstance() check only verifies that the required methods exist — it doesn't check their actual signatures or return types at runtime, which remains purely a static-checking concern.

TypedDict: precise types for shaped dictionaries

Real-world code frequently works with dictionaries that have a fixed, known set of string keys — parsed JSON, API responses. TypedDict lets you declare exactly that shape:

from typing import TypedDict

class UserResponse(TypedDict):
    id: int
    name: str
    email: str

def process_user(user: UserResponse) -> str:
    return f"{user['name']} ({user['email']})"

user: UserResp {"id": 1, "name": "Alex", "email": "[email protected]"}
print(process_user(user))

A static type checker now knows exactly which keys UserResponse should have, and their expected types — catching a typo'd key name, or a value of the wrong type, as an error, exactly the way it would for a regular class's attributes.

NotRequired and Required for optional keys

Since Python 3.11 (via PEP 655), individual TypedDict keys can be explicitly marked optional or mandatory:

from typing import TypedDict, NotRequired

class UserResponse(TypedDict):
    id: int
    name: str
    email: NotRequired[str]   # this key is optional

user: UserResp {"id": 1, "name": "Alex"}   # valid — email was omitted
A crucial caveat: purely a static-checking construct

This is genuinely important to internalize: a TypedDict is just a plain dict at runtime, with absolutely no actual enforcement. Nothing about declaring UserResponse as a TypedDict prevents you from creating one with the wrong types, or missing required keys, at runtime:

bad_user: UserResp {"id": "not an int", "name": 42}   # no error at runtime at all!

A static type checker like Mypy would flag this immediately as invalid — but Python itself runs this code without any complaint whatsoever, exactly the same "hints are advisory, never enforced" principle covered throughout this entire article and the earlier annotations article.

Putting It to Work: mypy and Runtime Validation

Installing and running Mypy
pip install mypy
mypy src/

Running Mypy against your code analyzes every annotation covered throughout this article — unions, generics, Protocols, TypedDicts — and reports genuine type inconsistencies before you ever actually run the program. This is where all of these annotations actually pay off concretely, rather than remaining purely documentation.

Type stubs for untyped libraries

Many popular third-party libraries either ship their own type hints, or have separate "stub" packages providing them:

pip install types-requests

Installing types-requests gives Mypy the type information it needs to correctly check code using the requests library, even though requests itself might not (or might only partially) include inline type hints in its own source.

Practical guidance on adoption

Don't attempt to type an entire existing codebase all at once — that's a genuinely large, often discouraging undertaking. Start with critical function signatures — the ones most frequently called across your codebase, or the ones where a type mismatch would cause the most damage — and progressively introduce TypedDict or dataclass (covered in the earlier dataclasses article) for structured data as your comfort and confidence grow. Type hints are additive; a partially typed codebase still gets meaningful value from Mypy checking the parts that are typed, without requiring complete coverage before any benefit appears.

Where runtime validation actually fits in

Everything covered in this article — typing, Mypy, Protocol, TypedDict — is purely advisory and static: useful for readers, useful for tooling, but never actually enforced while your program runs, as demonstrated concretely in Section 4. For situations where you genuinely need real, runtime validation and coercion — rejecting malformed data the moment it enters your program, not just flagging it during a separate static-analysis pass — libraries like Pydantic build directly on top of Python's type hint syntax to provide exactly that: declaring a model using familiar type-hint syntax, but with Pydantic actually validating and even coercing incoming data against those types at runtime, raising a real, immediate exception on a mismatch, rather than merely something Mypy would flag if you happened to run it separately. Pydantic (and similar libraries) is worth exploring as the natural next step once you're comfortable with everything covered in this article, specifically for any situation where "advisory, checked separately by a tool" genuinely isn't enough, and you need types actively enforced as your program runs.