Skip to main content

2.8 - Type Hints and Generics

Python remains a dynamically typed language, but since Python 3.5 (PEP 484) it supports optional type hints: annotations that document the expected types of variables, parameters, and return values. Type hints are not enforced at runtime — Python ignores them when executing your code — but tools like mypy, pyright, and modern IDEs use them to catch type errors before you run anything.

2.8.1 - Annotating Variables and Functions

Variable annotations use a colon after the name; function annotations use a colon after each parameter and an arrow (->) before the return type:

age: int = 30
name: str = "Alice"

def greet(name: str, times: int = 1) -> str:
return (f"Hello, {name}! " * times).strip()

Since Python 3.10 (PEP 604), you can express "this type or None" (and unions in general) with | instead of typing.Optional and typing.Union:

def find_user(user_id: int) -> "User | None":
...

def parse(value: int | str) -> float:
...

2.8.2 - Generic Containers

Since Python 3.9 (PEP 585), the built-in collection types can be used directly as generics, without importing typing.List, typing.Dict, and similar aliases:

def average(values: list[float]) -> float:
return sum(values) / len(values)

def word_counts(text: str) -> dict[str, int]:
counts: dict[str, int] = {}
for word in text.split():
counts[word] = counts.get(word, 0) + 1
return counts

2.8.3 - Protocols and Structural Typing

typing.Protocol (Python 3.8+, PEP 544) defines a structural type — an interface based on what an object can do, not what class it inherits from. Any object with matching methods satisfies the protocol, without explicit inheritance:

from typing import Protocol

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

class DatabaseConnection:
def close(self) -> None:
print("Connection closed")

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

shut_down(DatabaseConnection()) # Type-checks: DatabaseConnection has a matching close()

This is Python's version of "duck typing," made explicit enough for static type checkers to verify.


2.8.4 - Function Overloads

@typing.overload (Python 3.5+) documents multiple valid call signatures for a single function whose behavior depends on the type of its arguments. The overload declarations are only used by type checkers; the real implementation is the final, undecorated definition:

from typing import overload

@overload
def process(value: int) -> int: ...
@overload
def process(value: str) -> str: ...
def process(value):
if isinstance(value, int):
return value * 2
return value.upper()

A type checker sees process(5) as returning int and process("hi") as returning str, even though there's only one real function body.


2.8.5 - PEP 695 Generic Syntax (Python 3.12)

Prior to Python 3.12, writing a generic function or class required an explicit TypeVar declared outside the function or class:

from typing import TypeVar

T = TypeVar("T")

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

Python 3.12 (PEP 695) introduces a more compact, built-in syntax where the type parameter is declared in square brackets right after the function or class name, scoped to that definition:

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

class Stack[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()

The same syntax also introduces the type statement for declaring type aliases, replacing typing.TypeAlias:

type Point = tuple[float, float]

def distance(a: Point, b: Point) -> float:
return ((a[0] - b[0]) ** 2 + (a[1] - b[1]) ** 2) ** 0.5

Type aliases declared with type can be generic too:

type Pair[T] = tuple[T, T]

2.8.6 - Deferred Evaluation of Annotations (Python 3.14)

Historically, annotations that referred to a class not yet defined (a "forward reference") had to be written as a string:

class Node:
def __init__(self, value: int, next: "Node | None" = None) -> None:
self.value = value
self.next = next

With Python 3.14 (PEP 649 and PEP 749), annotations are no longer evaluated eagerly when a function, class, or module is defined. Instead, they're stored in a lazily-called annotate function and only evaluated on demand, so forward references no longer need to be quoted:

class Node:
def __init__(self, value: int, next: Node | None = None) -> None:
self.value = value
self.next = next

The new annotationlib module provides tools for reading these deferred annotations at runtime, without triggering eager evaluation:

import annotationlib

def greet(name: str) -> str:
return f"Hello, {name}"

print(annotationlib.get_annotations(greet))
# {'name': <class 'str'>, 'return': <class 'str'>}

Code that reads __annotations__ directly still works in most cases, but annotationlib.get_annotations() is the more robust choice going forward, since it can request annotations in a string or forward-reference-safe form instead of forcing full evaluation.


2.8.7 - Summary

FeatureSyntaxSince
Basic annotationsdef f(x: int) -> str:3.5
Union shorthandint | None3.10
Built-in generic containerslist[int], dict[str, int]3.9
Structural typingclass C(Protocol):3.8
Function overloads@overload3.5
Compact generic syntaxdef f[T](x: T) -> T:, class C[T]:3.12
Type alias statementtype Point = tuple[float, float]3.12
Deferred annotation evaluation(automatic — no quoting needed)3.14

Type hints don't change how your program runs, but they document intent, catch bugs earlier when paired with a type checker, and make large codebases easier to navigate.