Type hints are engineering contracts for humans and tooling. They can drive runtime libraries, but ordinary Python execution does not turn annotations into validation.
Core answer
Annotate public boundaries, accept behavioral abstractions where callers need flexibility, and return concrete types when callers depend on concrete operations. Treat Any as an escape hatch, not as a wider spelling of object.
# [CURRENT - 3.10-3.14] Works on Python 3.10+ [PEP 604]from collections.abc import Iterable, Mappingfrom dataclasses import dataclass@dataclass(frozen=True, slots=True)class RenderedRow: line: str source: str | Nonedef render_rows(headers: Mapping[str, str], rows: Iterable[str]) -> list[RenderedRow]: prefix = headers.get("prefix", "") source = headers.get("source") return [RenderedRow(prefix + row, source) for row in rows]print(render_rows({"prefix": "ok:", "source": "queue"}, ["a", "b"]))Why this design exists
PEP 484 made gradual typing practical without replacing Python's dynamic runtime. PEP 544 added protocols so structural behavior can be typed without forcing inheritance. Later syntax PEPs such as PEP 585 and PEP 604 reduced annotation noise in modern code.
That design leaves a deliberate boundary: type checking can be strict where teams need it, while runtime parsing, validation, and coercion remain explicit code.
Mechanics and CPython internals
Annotations are stored on functions and classes and are consumed by tools and libraries such as dataclasses. Their evaluation model has changed over Python versions; runtime consumers should use supported inspection APIs such as typing.get_type_hints() instead of assuming raw annotation storage is already fully resolved.
# [CURRENT - 3.10-3.14] Works on Python 3.10+from dataclasses import dataclassfrom typing import Protocol, get_type_hintsclass Writer(Protocol): def write(self, payload: bytes) -> int: ...@dataclass(frozen=True, slots=True)class Envelope: topic: str payload: bytesdef emit(writer: Writer, envelope: Envelope) -> int: return writer.write(envelope.payload)class Buffer: def __init__(self) -> None: self.data = bytearray() def write(self, payload: bytes) -> int: self.data.extend(payload) return len(payload)print(get_type_hints(emit))print(emit(Buffer(), Envelope("audit", b"ready")))Complexity and tradeoffs
Hints cost almost nothing in normal execution, but their abstraction choice costs design attention. Abstract inputs improve substitutability; overly broad unions and Any move errors later. Runtime inspection of complex hints has real work and version semantics, so do it at framework boundaries rather than in hot business loops.
Idiomatic patterns and refactoring
Refactor an Any-shaped boundary into explicit parsing and a precise domain record before the value enters the rest of the system.
# [CURRENT - 3.10-3.14] Works on Python 3.10+from dataclasses import dataclassfrom typing import Any@dataclass(frozen=True, slots=True)class Limit: value: intdef parse_limit_bad(payload: dict[str, Any]) -> Any: return payload.get("limit", 100)def parse_limit(payload: dict[str, object]) -> Limit: raw = payload.get("limit", 100) if not isinstance(raw, int) or raw < 1: raise ValueError("limit must be a positive integer") return Limit(raw)print(parse_limit_bad({"limit": "100"}))print(parse_limit({"limit": 100}))Common mistakes and edge cases
str | None means None is a permitted value; it does not make a parameter optional unless a default exists. Parameterized generics such as list[int] are annotation syntax, not a valid runtime isinstance target. Static type safety and untrusted-input validation are separate jobs.
When to use / When NOT to use
Use hints to make boundaries, ownership, and expected behavior visible to checkers and readers. Use protocols when behavior matters more than inheritance.
Do not use hints as security policy, data cleansing, or runtime validation by wishful thinking. Do not leak Any through a codebase simply because one integration boundary is dynamic.