Skip to main content
Python advanced Lesson 28 of 28

Python Type Hints and Static Typing

Write self-documenting, safer Python code using type annotations, generics, Protocol, TypeVar, and runtime validation with Pydantic.

Python’s type hint system (introduced in PEP 484) allows you to annotate variables, function parameters, and return values with types. Static analysers like mypy and pyright use these hints to catch bugs before your code runs.

Basic Annotations

Type annotations are the foundation of static typing in Python. They serve two purposes: they tell a static analyser what types to expect so it can flag mismatches, and they serve as inline documentation that makes function signatures self-explanatory without requiring a docstring. Annotations have zero runtime cost — the interpreter records them in __annotations__ but never enforces them unless you add a runtime validator.

# Variable annotations describe what a variable is allowed to hold
name: str = "Alice"
age: int = 30
scores: list[float] = [9.5, 8.2, 7.8]
config: dict[str, str] = {"env": "production"}

# Function annotations describe inputs and the return value
# mypy will flag any call site that passes the wrong type
def greet(name: str, greeting: str = "Hello") -> str:
    return f"{greeting}, {name}!"

# -> None makes it explicit that this function has no meaningful return value
def log_event(message: str) -> None:
    print(f"[LOG] {message}")

Union Types and Optional

Real-world functions often accept more than one type — a value that could be a string or an integer, or a result that could be None when not found. Union types let you express this precisely. In Python 3.10+ you write X | Y directly; in earlier versions you use Union[X, Y] from typing. Optional[X] is shorthand for X | None and is very common for function return values that signal “not found” with None.

from typing import Optional

# Python 3.10+ — the | syntax is concise and readable
def parse_int(value: str | int) -> int | None:
    try:
        return int(value)
    except (ValueError, TypeError):
        return None   # explicitly typed as a valid return value

# Pre-3.10 equivalent using Optional from typing
def parse_int_compat(value: str) -> Optional[int]:
    try:
        return int(value)
    except ValueError:
        return None

Collection Type Hints

When annotating collection parameters, prefer abstract types from typing over concrete ones like list or dict. A function that only needs to iterate over values should accept Iterable, not list — this makes it work with lists, tuples, generators, and any other iterable without changes. Using the most general type that satisfies your needs is called the Liskov substitution principle, and it makes your functions far more reusable.

from typing import Sequence, Mapping, Iterable

# Sequence accepts list, tuple, str — anything ordered and subscriptable
def sum_scores(scores: Sequence[float]) -> float:
    return sum(scores)

# Mapping accepts dict, OrderedDict, defaultdict, and any dict-like object
def print_headers(headers: Mapping[str, str]) -> None:
    for key, value in headers.items():
        print(f"{key}: {value}")

# Iterable is the broadest — accepts any object you can loop over
def process_items(items: Iterable[str]) -> list[str]:
    return [item.strip().lower() for item in items]

# Callable describes a function as a value — useful for callbacks and higher-order functions
from typing import Callable

# Callable[[arg_type, ...], return_type]
def apply(func: Callable[[int, int], int], a: int, b: int) -> int:
    return func(a, b)

result = apply(lambda x, y: x + y, 3, 4)   # 7

TypeVar — Generic Functions

TypeVar solves a specific problem: how do you annotate a function that returns the same type it receives, without losing type information? Without TypeVar, you’d have to write -> Any, which defeats the purpose of typing. With TypeVar, the type checker tracks the relationship between input and output — if you pass a list[int], it knows the return is int | None, not just Any.

from typing import TypeVar, Sequence

T = TypeVar("T")   # T is a stand-in for "whatever concrete type the caller uses"

def first(items: Sequence[T]) -> T | None:
    """Return the first element, or None if empty."""
    return items[0] if items else None

# mypy resolves T to int here — the return type is int | None, not Any
x: int | None = first([1, 2, 3])
# mypy resolves T to str here — the return type is str | None
s: str | None = first(["a", "b", "c"])

Generic Classes

Generic classes extend the TypeVar concept to data structures. A Stack that stores integers is a different type from a Stack that stores strings, and the type checker should enforce that you don’t mix them. Inheriting from Generic[T] makes the class parameterizable so callers can write Stack[int] or Stack[str], and the type checker will verify that all methods respect the declared element type.

from typing import Generic, TypeVar

T = TypeVar("T")

class Stack(Generic[T]):
    def __init__(self) -> None:
        self._items: list[T] = []   # T will be resolved when Stack is instantiated

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

    def pop(self) -> T:
        if not self._items:
            raise IndexError("Stack is empty")
        return self._items.pop()

    def peek(self) -> T:
        return self._items[-1]

    def __len__(self) -> int:
        return len(self._items)

# mypy now knows this stack only holds ints — pushing a str would be a type error
stack: Stack[int] = Stack()
stack.push(1)
stack.push(2)
print(stack.pop())   # 2 — typed as int, not Any

Protocol — Structural Subtyping

Abstract Base Classes require explicit inheritance, which creates tight coupling between unrelated modules. Protocol takes the opposite approach: any class that implements the required methods automatically satisfies the protocol, no inheritance needed. This is Python’s way of formalizing duck typing — the principle that “if it walks like a duck and quacks like a duck, it is a duck.” It is ideal for defining interfaces between components that shouldn’t depend on each other directly.

from typing import Protocol, runtime_checkable

# @runtime_checkable allows isinstance() checks at runtime, not just static analysis
@runtime_checkable
class Drawable(Protocol):
    def draw(self) -> str:
        ...   # protocol bodies use ... — no implementation needed

class Circle:
    def draw(self) -> str:
        return "○"

class Square:
    def draw(self) -> str:
        return "□"

class TextLabel:
    def draw(self) -> str:
        return "[Label]"

# render_all accepts any object with a .draw() method — no import of Drawable required
def render_all(shapes: list[Drawable]) -> None:
    for shape in shapes:
        print(shape.draw())

# None of these inherit from Drawable — they satisfy it structurally
render_all([Circle(), Square(), TextLabel()])

# Runtime check works because of @runtime_checkable
print(isinstance(Circle(), Drawable))   # True

TypedDict — Typed Dictionaries

Plain dictionaries give the type checker no information about which keys exist or what types their values have. TypedDict fixes this by defining the exact shape of a dictionary. The type checker will flag accesses to undefined keys, catch type mismatches in values, and even distinguish between required and optional keys using NotRequired. It is a lightweight alternative to a dataclass when you need to stay compatible with code that expects plain dicts.

from typing import TypedDict, NotRequired

class UserRecord(TypedDict):
    id: int
    name: str
    email: str
    role: NotRequired[str]   # this key may be absent — the checker won't require it

def create_user(data: UserRecord) -> None:
    print(f"Creating user: {data['name']}")

# The checker verifies all required keys are present and have the right types
create_user({"id": 1, "name": "Alice", "email": "alice@example.com"})

Pydantic — Runtime Validation

Type hints are erased at runtime, so they cannot protect you from bad data coming in from an HTTP request, a config file, or a database. Pydantic bridges this gap: it uses your type annotations to validate and coerce data at runtime, raising a clear ValidationError when something doesn’t match. This is the standard approach for validating API request bodies in FastAPI and for parsing configuration files. Install with pip install pydantic.

from pydantic import BaseModel, field_validator
from pydantic import Field

class UserRequest(BaseModel):
    # Field() adds validation constraints on top of the type hint
    name: str = Field(min_length=2, max_length=100)
    age: int = Field(ge=0, le=150)   # ge = greater-or-equal, le = less-or-equal
    email: str

    @field_validator("name")
    @classmethod
    def name_must_not_contain_numbers(cls, v: str) -> str:
        if any(char.isdigit() for char in v):
            raise ValueError("Name must not contain numbers")
        return v.strip()   # validators can also transform the value


# Valid input — Pydantic coerces compatible types automatically
user = UserRequest(name="Alice", age=30, email="alice@example.com")
print(user.model_dump())   # {'name': 'Alice', 'age': 30, 'email': 'alice@example.com'}

# Invalid input — raises ValidationError with a clear description of every problem
try:
    bad = UserRequest(name="A", age=-5, email="not-an-email")
except Exception as e:
    print(e)

Type Narrowing

Static type checkers track the possible types of a variable through control flow — a technique called type narrowing. When you check if isinstance(value, int):, the checker knows that inside that branch value is definitely an int, not a str or None. This means you can write safe, branch-specific code without casts, and the checker will still verify correctness in each branch independently.

def process(value: int | str | None) -> str:
    if value is None:
        return "no value"          # checker knows: value is None in this branch

    if isinstance(value, int):
        return f"integer: {value}" # checker knows: value is int in this branch

    return f"string: {value}"      # checker knows: value is str here (None and int are ruled out)

Running mypy

Once your code is annotated, mypy is the standard tool for checking it. Running it regularly — ideally in CI — catches type errors before they reach production. The --strict flag is the recommended starting point for new projects: it enables every optional check and ensures no function is left unannotated.

pip install mypy
mypy your_script.py --strict

The --strict flag enables all optional checks including --disallow-untyped-defs, --no-implicit-optional, and --warn-return-any.

Frequently Asked Questions

Do Python type hints affect runtime performance?
No. Type hints are ignored by the Python interpreter at runtime by default. They only provide information to static analysis tools like mypy, pyright, and IDEs. Using them has zero runtime cost unless you explicitly inspect __annotations__ or use a runtime validation library like Pydantic.
What is the difference between Protocol and ABC?
An ABC (Abstract Base Class) uses nominal subtyping — a class must explicitly inherit from it. Protocol uses structural subtyping (duck typing) — any class that implements the required methods satisfies the Protocol, without needing to inherit from it.
What is TypeVar used for?
TypeVar creates a placeholder type that can be resolved to any concrete type. It allows you to write generic functions and classes that work with multiple types while still preserving type relationships (e.g. a function that returns the same type it receives).