Python Atlas
Core language

Type hints and annotations

Model precise contracts with modern unions, generics, protocols, narrowing, and postponed annotation evaluation.

Annotations attach metadata to functions, classes, and variables. Type checkers use that metadata to find inconsistent calls without executing code. At runtime, Python ordinarily accepts values regardless of annotations:

def double(value: int) -> int:
    return value * 2


assert double("ha") == "haha"  # Runs, although a type checker should reject it.

Use validation libraries or explicit checks at untrusted boundaries. Static typing and runtime validation solve different problems.

Precise everyday types

from collections.abc import Iterable, Mapping


def summarize(
    values: Iterable[float],
    labels: Mapping[str, str] | None = None,
) -> tuple[int, float]:
    materialized = tuple(values)
    return len(materialized), sum(materialized)

Use abstract input types when only abstract behavior is required and concrete return types when callers receive a specific object. Modern syntax includes:

  • list[str] rather than typing.List[str].
  • User | None rather than Optional[User].
  • collections.abc.Callable and collection interfaces.
  • Literal for a closed set of literal values.
  • TypedDict for dictionaries with a known key shape.

Avoid annotating everything as Any. Any permits every operation and allows the resulting uncertainty to spread. Use object when a value can be anything but must be inspected or narrowed before use.

Narrowing unions

def normalize_identifier(value: int | str) -> str:
    if isinstance(value, int):
        return str(value)
    return value.strip().lower()

The branch narrows value for a type checker. Other narrowing tools include is None, pattern matching, TypeGuard, and assertion functions. Do not cast merely to silence an error: typing.cast changes the checker's view but performs no runtime conversion or validation.

from typing import TypeGuard


def is_string_list(values: list[object]) -> TypeGuard[list[str]]:
    return all(isinstance(value, str) for value in values)

Generics preserve relationships

A generic says that types at different positions are related:

from collections.abc import Iterable
from typing import TypeVar

T = TypeVar("T")


def first(values: Iterable[T]) -> T:
    iterator = iter(values)
    try:
        return next(iterator)
    except StopIteration as error:
        raise ValueError("expected at least one value") from error

Passing Iterable[Customer] produces a Customer; passing Iterable[str] produces a str. Returning object would lose this relationship, while Any would hide mistakes after the call.

Python 3.12 introduced type-parameter syntax:

def identity[T](value: T) -> T:
    return value


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

Use this syntax only when the project's minimum Python version and type checker support it. TypeVar remains appropriate for libraries supporting older Python.

Variance in one practical example

If Cat is an Animal, a producer of cats can safely serve where a producer of animals is expected, but a mutable list[Cat] cannot serve as list[Animal]. The latter would let a caller append a Dog to a list promised to contain cats.

Read-only interfaces such as Sequence are often covariant; mutable containers are generally invariant. Model the operations consumers need instead of forcing concrete mutable collections through unsafe casts.

Callable signatures

from collections.abc import Callable
from typing import ParamSpec, TypeVar

P = ParamSpec("P")
R = TypeVar("R")


def invoke(
    function: Callable[P, R],
    *args: P.args,
    **kwargs: P.kwargs,
) -> R:
    return function(*args, **kwargs)

ParamSpec captures a complete parameter list. It is especially valuable for decorators. Callable[..., R] preserves only the return type and allows any arguments, so use it only when the parameters are genuinely unknown.

Protocols and typed mappings

Use a protocol for object behavior and TypedDict for a dictionary schema:

from typing import Protocol, TypedDict


class UserPayload(TypedDict):
    id: int
    display_name: str


class UserSource(Protocol):
    def fetch(self, user_id: int) -> UserPayload | None: ...

TypedDict values remain ordinary dictionaries at runtime. If construction, validation, methods, or runtime identity matter, use a data class or validation model instead.

from __future__ import annotations

Historically, annotations were evaluated as function and class definitions ran, which made forward references and import cycles awkward. This future import stores annotations in a postponed form:

from __future__ import annotations


class Node:
    def __init__(self, children: list[Node] | None = None) -> None:
        self.children = children or []

Without postponed evaluation on affected Python versions, Node would need to be quoted because its class body has not finished. The future import must appear near the top of the module.

Do not assume __annotations__ contains ready-to-use classes. Use typing.get_type_hints() when runtime code must resolve annotations, and be aware that resolving annotations may require namespaces and can execute annotation-related evaluation. Avoid using annotation evaluation on untrusted definitions.

Python's annotation behavior continues to evolve by version. Libraries that inspect annotations must test against every supported interpreter rather than depending on raw storage details.

Tradeoffs and pitfalls

  • Precise types improve refactoring but overly complex types can obscure design.
  • # type: ignore should include a specific error code and an explanation.
  • Final and ClassVar communicate intent; they are not runtime enforcement.
  • NewType distinguishes logically different values statically with almost no runtime cost, but does not perform validation.
  • @overload describes multiple call signatures; one runtime implementation is still required.
  • Type hints for third-party data do not make that data valid.

Practice

  1. Replace an Any return with a generic that preserves the input element type.
  2. Define a protocol for a cache with typed get and put behavior.
  3. Model a JSON object first with TypedDict, then with a data class. Explain which boundary each suits.
  4. Use get_type_hints() on a class with a forward reference and compare its result with raw __annotations__.

LEARNING RECORD

Finish this lesson

Mark it complete when you can explain the main decision without looking.

KNOWLEDGE CHECK

Type hints and annotations

1When should `object` usually be preferred to `Any` for an unknown input?
2Why is `def first(values: Iterable[T]) -> T` more precise than returning `object`?