Back to Blog
Python

Python Typing: A Practical Guide to Type Hints

python typing: Understand Python typing: how type hints behave at runtime, use built-in generics, Protocol, and type aliases, and pick the right annotation style.

type hintsstatic analysismypypython 3code quality
Illustration of Python type annotations guiding static analysis, with highlighted code blocks flowing into a validation icon.

Python typing, defined in PEP 484, is a system of annotations attached to function signatures and variable declarations. The critical thing to understand is that these annotations are not enforced by the interpreter. When you write:

def process_order(order_id: int) -> str: return f"Order {order_id}"

The : int and -> str are stored in the function's __annotations__ attribute, but nothing validates that order_id is actually an integer when the function is called. Passing a string works fine at runtime. Type checking happens in a separate step, typically through a static analysis tool like mypy, pyright, or pyre, usually integrated into your editor or CI pipeline.

This separation is the most important mental model for working with Python typing. The annotations serve as documentation and as input to static checkers, not as runtime guards. If you need runtime validation, you need a separate mechanism such as Pydantic or manual checks.

Core Annotation Syntax

Function annotations come in two places: parameter types and return types.

def calculate_total(items: list[float], discount: float = 0.0) -> float: return sum(items) * (1 - discount)

Variable annotations are also supported:

user_id: int = 42 cache: dict[str, bytes] = {}

The variable annotation syntax is most useful when the type cannot be inferred from the initial assignment, or when you want to declare a variable that will be assigned later:

result: float | None = None

For local variables where the type is obvious from the assignment, annotations add noise without adding information. Reserve them for cases where inference is ambiguous or where the type changes across branches.

Built-in Generics Versus the typing Module

Python 3.9 made built-in collections usable as generic types. Before that, you had to import from typing:

# Python 3.8 and earlier from typing import Dict, List, Optional def find_users(ids: List[int]) -> Optional[Dict[str, str]]: ...
# Python 3.9+ def find_users(ids: list[int]) -> dict[str, str] | None: ...

The modern syntax is shorter, reads more naturally, and avoids the import overhead. If your project targets Python 3.9 or newer, use the built-in generics. The typing module still exists for things that have no built-in equivalent: Optional, Union, Literal, Callable, Any, TypeVar, and others.

Note that Optional[X] and X | None are equivalent. Python 3.10 introduced the | union syntax, so str | None works from that version onward.

SyntaxMinimum PythonExample
typing module generics3.5List[int]
Built-in generics3.9list[int]
| union syntax3.10str | None
type statement3.12type Alias = ...

Type Aliases and NewType

A type alias gives a meaningful name to a complex type:

UserId = int Coordinates = tuple[float, float] def distance(a: Coordinates, b: Coordinates) -> float: ...

Python 3.12 added the type statement, which makes aliases more explicit:

type Coordinates = tuple[float, float]

NewType creates a distinct type that is treated as a different type by the static checker but is identical to the underlying type at runtime:

from typing import NewType UserId = NewType("UserId", int) def get_user(user_id: UserId) -> User: ...

The static checker will reject passing a plain int where a UserId is expected, even though UserId(42) and 42 are the same object at runtime. This is useful for preventing unit confusion or mixing IDs from different domains.

Protocol for Structural Typing

Protocol (PEP 544) lets you define a structural type: a set of attributes or methods that an object must have, without requiring it to inherit from a specific class.

from typing import Protocol class Drawable(Protocol): def draw(self) -> None: ... class Circle: def draw(self) -> None: print("drawing circle") def render(obj: Drawable) -> None: obj.draw() render(Circle()) # valid: Circle has a draw method

This is closer to Go's interface style than to nominal typing. It is useful when you want to accept any object that satisfies an interface, even if it does not inherit from a common base class. The ... in the Protocol body is an ellipsis literal used as a placeholder for the method body.

Runtime Overhead of Type Annotations

Type annotations themselves have negligible runtime cost. They are evaluated when the function is defined, stored in __annotations__, and then ignored. The main cost appears when annotations reference types that are expensive to import or when you use from __future__ import annotations, which defers evaluation by storing annotations as strings.

One practical concern: if your annotations import heavy modules just to reference a type, that import cost is paid at module load time. Using string annotations or TYPE_CHECKING guards can avoid this:

from typing import TYPE_CHECKING if TYPE_CHECKING: from database import Database def connect(db: "Database") -> None: ...

TYPE_CHECKING is True only when a static checker is analyzing the code, so the Database import never executes at runtime. The string annotation "Database" is resolved by the static checker.

Choosing the Right Annotation Style

The decision between Optional[X] and X | None depends on your minimum Python version. The decision between typing.List and list depends on whether you support Python 3.8. The decision between Protocol and an abstract base class depends on whether you control the classes being passed in.

For a new codebase targeting Python 3.10+, use the | union syntax and built-in generics. Reserve typing imports for Literal, Callable, TypeVar, Protocol, and similar constructs that have no built-in equivalent. Keep annotations readable: if a type expression becomes too long, extract a type alias rather than repeating it across function signatures.

python typing: Practical Usage and Code Examples | RYUSLOG DEV