Contents
  1. What Type Hints Are and What They Are Not
  2. Basic Annotations
  3. Optional and Union
  4. TypeVar: Preserving Type Relationships
  5. Protocol: Structural Subtyping
  6. Generics: Parameterised Classes
  7. When Type Hints Pay Off and When They Add Noise
  8. What You Can Do Now
← All posts

Annotating Python: Type Hints from Basics to Generics

Python's type hint system spans simple variable annotations, Optional and Union, TypeVar, Protocol, and generic classes. Each construct serves a different role in expressing intent to static checkers without changing runtime behaviour.

Python’s annotation syntax has existed since version 3.0, but the type hint system built on top of it took its current shape with PEP 484. The typing module and the conventions it standardises give static analysis tools, IDEs, and readers of code a way to reason about types without requiring Python itself to enforce them. Understanding where each construct fits prevents both under-annotation, where the checker cannot help, and over-annotation, where every variable carries a type that contributes nothing.

What Type Hints Are and What They Are Not

The Python runtime does not enforce function and variable annotations. An annotation records intent for tools such as mypy, pyright, and similar checkers, but Python will not raise an error at runtime if a caller passes the wrong type. PEP 484 is explicit on this point: type checking happens offline, not during execution. Annotations are stored in a function’s __annotations__ attribute and are accessible at runtime, but they carry no enforcement weight.

def greet(name: str) -> str:
    return "Hello, " + name

greet(42)  # Runs without error at runtime; a static checker flags it

This separation between annotation and enforcement is deliberate. Python remains a dynamically typed language. Type hints are a voluntary layer, not a mode change.

Basic Annotations

Variables, function parameters, and return values are annotated with the type placed after a colon. Built-in types are used directly.

def summarise(title: str, word_count: int, tags: list[str]) -> dict[str, int]:
    return {"length": word_count}

From Python 3.9 onward, built-in collection types such as list, dict, tuple, and set accept subscript parameters directly. Before 3.9, the capitalised equivalents from typing, namely List, Dict, and Tuple, were required. Tuples deserve a note: tuple[int, str] is a fixed-length two-element tuple, while tuple[int, ...] is a variable-length tuple of integers.

coordinates: tuple[float, float] = (51.5, 0.1)
scores: tuple[int, ...] = (10, 20, 30, 40)

Optional and Union

Optional[T] represents a value that is either of type T or None. It is shorthand for Union[T, None]. The two are interchangeable, and Python 3.10 introduced the X | Y pipe syntax as a third equivalent form.

from typing import Optional, Union

def find_user(user_id: int) -> Optional[str]:
    ...

# These three annotations express the same type:
def process(value: Optional[int]) -> None: ...
def process(value: Union[int, None]) -> None: ...
def process(value: int | None) -> None: ...

A common point of confusion: Optional indicates that None is a valid value, not that the argument itself is optional in the function call. A parameter with a default value of 0 does not need Optional. A parameter that may explicitly receive None does.

Union is not limited to two members. Union[str, int, bytes] is valid and tells the checker that any of those three types is acceptable. When narrowing is needed inside the function body, isinstance checks guide the checker to the correct branch.

def handle(value: Union[str, int]) -> str:
    if isinstance(value, int):
        return str(value)
    return value.upper()

TypeVar: Preserving Type Relationships

TypeVar is used to write functions that are generic over a type while preserving the relationship between input and output types. Without it, a function that accepts any type and returns it can only be annotated with Any, losing all information about what type comes back.

from typing import TypeVar, Sequence

T = TypeVar("T")

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

Here, if first is called with a list[str], the checker infers that the return type is str. The type variable T acts as a placeholder that is resolved at each call site.

A TypeVar can be constrained to a specific set of types, which means the variable must be exactly one of those types at each call site, not a mix.

AnyStr = TypeVar("AnyStr", str, bytes)

def concat(x: AnyStr, y: AnyStr) -> AnyStr:
    return x + y

concat("hello", " world")   # OK, both str
concat(b"hello", b" world") # OK, both bytes
concat("hello", b" world")  # Error: types cannot be mixed

A TypeVar can also carry an upper bound. TypeVar("T", bound=SomeClass) means the type must be SomeClass or any subclass of it. The checker then treats the resolved type as the most specific type known at the call site, not the bound itself.

Protocol: Structural Subtyping

Protocol, introduced in Python 3.8, allows structural typing. A class satisfies a Protocol if it implements the required attributes and methods, regardless of whether it explicitly inherits from the protocol. This formalises duck typing in a way that static checkers can verify.

from typing import Protocol

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

class Circle:
    def draw(self) -> None:
        print("Drawing circle")

class Square:
    def draw(self) -> None:
        print("Drawing square")

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

render(Circle())  # Valid
render(Square())  # Valid

Neither Circle nor Square inherits from Drawable. The checker accepts both because both satisfy the structural contract. This is nominal typing’s opposite: the shape of the object matters, not its declared lineage.

When runtime isinstance checks against a protocol are needed, the protocol must be decorated with @runtime_checkable. This makes isinstance(obj, Drawable) legal at runtime, though only attribute presence is verified, not type signatures.

Generics: Parameterised Classes

A class can be made generic by inheriting from Generic[T], where T is a TypeVar. This allows instances to carry type information that the checker tracks through method signatures.

from typing import TypeVar, Generic

T = TypeVar("T")

class Box(Generic[T]):
    def __init__(self, value: T) -> None:
        self.value = value

    def get(self) -> T:
        return self.value

int_box: Box[int] = Box(42)
str_box: Box[str] = Box("hello")

Python 3.12 introduced a more concise syntax using type parameter declarations directly in the class statement, but the Generic[T] form remains widely used for compatibility with earlier versions. In either case, the type parameters exist only for the checker. At runtime, Box[int] and Box[str] are both instances of Box with no type distinction.

Built-in generic aliases such as list[str] and dict[str, int] work on the same principle. dict[str, int] tells the checker that keys are strings and values are integers. Operations on keys or values are then checked accordingly.

When Type Hints Pay Off and When They Add Noise

Type hints are most valuable in function and method signatures, particularly at module boundaries where a caller cannot inspect the implementation. Return types on public functions, parameter types that are not self-evident from context, and any use of Optional where callers might assume a value is always present are all places where annotations prevent errors.

They add noise when applied mechanically to every local variable, especially those with obvious inferred types. Annotating count: int = 0 offers no information that the right side does not already provide. Static checkers infer the type from the assignment and do not need the annotation. Short-lived intermediate variables inside a private function are also poor candidates for annotation, since the checker tracks them well from context and the annotation interrupts the flow of the code without adding clarity.

The practical boundary is the function signature. Annotate inputs and outputs consistently. Let the checker infer the interior.

What You Can Do Now

The following module demonstrates all of the constructs from this post in a single runnable file. Run it through mypy with mypy --strict type_demo.py to see how the checker uses the annotations.

# type_demo.py

from typing import Optional, Union, TypeVar, Generic, Protocol

# --- Basic annotations ---

def word_count(text: str) -> int:
    return len(text.split())


# --- Optional ---

def find_tag(tags: list[str], prefix: str) -> Optional[str]:
    for tag in tags:
        if tag.startswith(prefix):
            return tag
    return None


# --- Union ---

def normalise(value: Union[str, int]) -> str:
    if isinstance(value, int):
        return str(value)
    return value.strip()


# --- TypeVar ---

T = TypeVar("T")

def last(items: list[T]) -> T:
    return items[-1]


# --- Protocol ---

class Serialisable(Protocol):
    def to_dict(self) -> dict[str, str]:
        ...

class User:
    def __init__(self, name: str, email: str) -> None:
        self.name = name
        self.email = email

    def to_dict(self) -> dict[str, str]:
        return {"name": self.name, "email": self.email}

def export(obj: Serialisable) -> dict[str, str]:
    return obj.to_dict()


# --- Generic class ---

V = TypeVar("V")

class Cache(Generic[V]):
    def __init__(self) -> None:
        self._store: dict[str, V] = {}

    def set(self, key: str, value: V) -> None:
        self._store[key] = value

    def get(self, key: str) -> Optional[V]:
        return self._store.get(key)


# --- Exercise ---

if __name__ == "__main__":
    print(word_count("hello world"))          # 2
    print(find_tag(["py-3.12", "web"], "py")) # py-3.12
    print(normalise(42))                       # 42
    print(last([10, 20, 30]))                  # 30

    cache: Cache[int] = Cache()
    cache.set("hits", 100)
    print(cache.get("hits"))                   # 100

    print(export(User("Ada", "ada@example.com")))
← All posts