Python’s type hints are optional, unenforced at runtime, and yet they’ve become one of the most consequential additions to the language since async/await. They don’t make Python statically typed. What they do is give you a shared vocabulary for describing shapes of data, a way to catch entire categories of bugs before your tests do, and editor support that turns “let me go check the implementation” into a tooltip.

This article covers the parts of the typing ecosystem you’ll actually reach for, the ones you’ll see in real codebases, and the tools that turn hints from documentation into enforcement.

The basics

A type hint on a variable, parameter, or return value is just an annotation. Python parses it, stores it, and otherwise ignores it at runtime:

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

age: int = 30

Nothing here is checked when you run the script. greet("Alice", "twice") executes without error and blows up wherever * chokes on a string times a string, if it blows up at all. The value of the hint shows up before runtime: in your editor, and in a separate type checker like mypy or pyright.

That separation is the single most important thing to understand about Python typing. Hints are metadata for tools, not runtime guards.

Collections and generics

Since Python 3.9, you can parameterize built-in collection types directly, no need to import List, Dict, or Tuple from typing:

def totals(prices: list[float]) -> float:
    return sum(prices)

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

def bounds(values: list[int]) -> tuple[int, int]:
    return min(values), max(values)

tuple is the one collection where the parameterization means something different: tuple[int, int] is a 2-tuple of two ints, while tuple[int, ...] (with a literal ellipsis) is a variable-length tuple of ints. list[int] and dict[str, int] describe homogeneous containers of arbitrary length.

If you’re on Python 3.8 or earlier, you need the typing equivalents (List, Dict, Tuple) since the builtins aren’t subscriptable yet. That’s increasingly rare to encounter, but it’s why you’ll still see from typing import List in older code.

Optional and union types

Optional[X] means “X or None,” and it’s shorthand for Union[X, None]:

from typing import Optional

def find_user(user_id: int) -> Optional[str]:
    if user_id == 1:
        return "Alice"
    return None

Since Python 3.10, the | operator works directly on types, which makes Union and Optional imports largely unnecessary:

def find_user(user_id: int) -> str | None:
    if user_id == 1:
        return "Alice"
    return None

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

str | None and Optional[str] are exactly equivalent, just newer syntax. Prefer | in new code targeting 3.10+; reach for Optional/Union only if you need to support older interpreters, though even then from __future__ import annotations lets you use | syntax while staying compatible at runtime (more on that below).

A common mistake is treating Optional[X] as “this parameter is optional,” i.e. has a default. It doesn’t. It only says the value, if provided, might be None. A truly optional parameter still needs = None (or another default) in the signature:

def connect(host: str, port: Optional[int] = None) -> None:
    ...

TypedDict for structured dicts

Plain dict[str, Any] throws away all the structure of a dict that actually has a fixed set of known keys, like a JSON API response. TypedDict lets you describe that shape:

from typing import TypedDict

class UserPayload(TypedDict):
    id: int
    name: str
    email: str
    is_admin: bool

def create_user(payload: UserPayload) -> None:
    print(f"Creating {payload['name']} <{payload['email']}>")

create_user({"id": 1, "name": "Alice", "email": "alice@example.com", "is_admin": False})

A type checker will flag a missing key, a misspelled key, or a wrong value type at the call site, the same class of bug that normally only surfaces as a KeyError deep inside a function. Mark individual keys optional with total=False on a separate class, or per-field with NotRequired:

from typing import NotRequired

class UserPayload(TypedDict):
    id: int
    name: str
    email: NotRequired[str]

TypedDict instances are still plain dicts at runtime; there’s no validation, no defaults, no methods. If you want actual runtime validation on top of the type information, that’s what a library like Pydantic is for.

Protocol for structural typing

Protocol describes an interface by shape rather than by inheritance. Anything with the right methods satisfies the protocol, no explicit subclassing required, this is Python’s version of duck typing made explicit and checkable:

from typing import Protocol

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

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

class DatabaseConnection:
    def close(self) -> None:
        print("closing connection")

class FileHandle:
    def close(self) -> None:
        print("closing file")

cleanup(DatabaseConnection())
cleanup(FileHandle())

Neither DatabaseConnection nor FileHandle inherits from SupportsClose. They satisfy it purely by having a matching close method. This is exactly how much of the standard library’s own typing works: open() returns something that satisfies file-like protocols, and code written against Protocol types accepts any object with the right shape instead of demanding a specific base class.

Generics with type parameters

When a function or class’s behavior is generic over some type, you can express that relationship instead of falling back to Any. Since Python 3.12, the syntax is built into function and class definitions:

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()

A type checker infers that first([1, 2, 3]) returns int, and that Stack[str]().pop() returns str, without you writing a single Any. On Python 3.11 and earlier, you write this with TypeVar instead:

from typing import TypeVar, Generic

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()

Both forms mean the same thing to a type checker; the 3.12 syntax is just less boilerplate.

Literal, Final, and overloads

Literal narrows a type to specific values rather than a whole type. It’s the right tool for flags, modes, and small closed sets of strings that aren’t quite worth an Enum:

from typing import Literal

def set_log_level(level: Literal["debug", "info", "warning", "error"]) -> None:
    print(f"log level set to {level}")

set_log_level("info")
set_log_level("verbose")  # type checker error: not a valid literal

Final marks a name as never meant to be reassigned:

from typing import Final

MAX_RETRIES: Final = 3

@overload lets you describe a function whose return type depends on its argument types, something a single signature can’t express:

from typing import overload

@overload
def parse(value: str) -> str: ...
@overload
def parse(value: bytes) -> str: ...
@overload
def parse(value: None) -> None: ...

def parse(value):
    if value is None:
        return None
    return value.decode() if isinstance(value, bytes) else value

Type checkers use the @overload stubs to pick the right return type at each call site; only the final, un-decorated implementation actually runs.

Deferred evaluation of annotations

Two forward-reference problems come up constantly: referring to a class inside its own methods before the class is fully defined, and wanting | union syntax on a Python version where it isn’t valid at runtime. Both are solved by deferring annotation evaluation:

from __future__ import annotations

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

With from __future__ import annotations, every annotation in the module is treated as a string and never evaluated at runtime, so Node can reference itself and int | None works even on Python 3.9. Type checkers still parse and check the string, they just don’t need Python itself to evaluate it. This import is cheap enough that many teams add it to every module by default.

Runtime enforcement with mypy

None of this matters if nothing ever checks it. mypy is the reference type checker; pyright (bundled with Pyright/Pylance in VS Code) is the other major option and tends to be faster and stricter by default.

pip install mypy
mypy src/

A minimal pyproject.toml config that catches real bugs without demanding you annotate an entire legacy codebase overnight:

[tool.mypy]
python_version = "3.12"
warn_return_any = true
warn_unused_ignores = true
disallow_untyped_defs = false
check_untyped_defs = true

disallow_untyped_defs = false lets you adopt hints incrementally, file by file, function by function, while check_untyped_defs = true still type-checks the bodies of unannotated functions using whatever inference it can do. Flip disallow_untyped_defs to true once a module is fully annotated to lock in the coverage.

Real-world pattern: typed configuration objects

Passing loosely-typed dicts around for configuration is a classic source of “wait, is it db_host or dbHost?” bugs. A typed dataclass turns that into something both readable and checked:

from dataclasses import dataclass

@dataclass(frozen=True)
class DatabaseConfig:
    host: str
    port: int = 5432
    username: str = "postgres"
    password: str = ""
    pool_size: int = 10

def connect(config: DatabaseConfig) -> None:
    print(f"connecting to {config.host}:{config.port} as {config.username}")

config = DatabaseConfig(host="db.internal", password="secret")
connect(config)

Misspell a field name in the constructor call and mypy catches it before the code runs. frozen=True adds immutability on top, so config objects can’t be mutated after construction, a nice property for anything passed around between modules.

Real-world pattern: validating function boundaries with Protocol

Protocol is especially useful for keeping application code decoupled from a specific dependency, letting you swap implementations (including a fake for tests) without touching call sites:

from typing import Protocol

class Cache(Protocol):
    def get(self, key: str) -> str | None: ...
    def set(self, key: str, value: str) -> None: ...

class RedisCache:
    def __init__(self, client) -> None:
        self._client = client

    def get(self, key: str) -> str | None:
        return self._client.get(key)

    def set(self, key: str, value: str) -> None:
        self._client.set(key, value)

class InMemoryCache:
    def __init__(self) -> None:
        self._data: dict[str, str] = {}

    def get(self, key: str) -> str | None:
        return self._data.get(key)

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

def get_or_compute(cache: Cache, key: str, compute) -> str:
    value = cache.get(key)
    if value is None:
        value = compute()
        cache.set(key, value)
    return value

get_or_compute doesn’t know or care whether it’s given a RedisCache or an InMemoryCache. Tests can pass InMemoryCache() with zero mocking, and the Protocol guarantees at check time that any future cache implementation actually implements the methods the function needs.

Gotchas

  • Hints are not validation. def f(x: int) does nothing to stop f("not an int") at runtime. If you need real enforcement at a boundary (parsing user input, an API request body), use a validation library, hints alone won’t save you.
  • Any defeats the type checker silently. Every value typed Any (or inferred as Any because an import couldn’t be resolved) turns off checking for everything downstream of it. A few unavoidable Anys are fine; a codebase full of them gives false confidence.
  • Mutable default arguments are still a bug, hints or not. def f(items: list[int] = []) is exactly as broken as the untyped version, the type hint doesn’t fix Python’s shared-default-object behavior. Use None and initialize inside the function.
  • Variance surprises with generics. list[int] is not treated as a list[float] even though int is assignable to float, because list is mutable and invariant. This trips people up coming from languages with more permissive generic variance rules.
  • Runtime isinstance checks against generic aliases mostly don’t work. isinstance(x, list[int]) raises TypeError. Generics are a static-checking concept; at runtime you can only check the bare container type: isinstance(x, list).

Summary

FeatureSyntaxUse case
Basic hintsdef f(x: int) -> str:Document and check parameter/return types
Collection genericslist[int], dict[str, int]Parameterize built-in containers
Union / Optionalstr | None, Optional[str]Value may be one of several types, or absent
TypedDictclass P(TypedDict): id: intDescribe the shape of a structured dict
Protocolclass P(Protocol): def close(self) -> None: ...Structural typing, duck typing made explicit
Genericsclass Stack[T]: / TypeVarTypes parameterized over another type
LiteralLiteral["debug", "info"]Restrict to a specific closed set of values
FinalMAX: Final = 3Mark a name as never reassigned
overload@overloadReturn type depends on argument type
Deferred evalfrom __future__ import annotationsForward references and newer union syntax on older Python

Type hints don’t change what Python runs, they change what tools can tell you about the code before it runs. Adopted incrementally, with mypy or pyright in CI, they turn a class of bugs that used to surface in production into a red squiggle in your editor.