Type Hints and Annotations#
Type hints annotate expected types for parameters, return values, variables, and attributes. They are stored in __annotations__ but are not enforced at runtime by default — static checkers (mypy, pyright, Pylance) and IDEs use them for verification and autocomplete. Python 3.10+ modernized syntax with X | Y unions, match integration, and PEP 695-style generics in 3.12.
How to use this page
Start with function signatures, then learn Protocol, TypedDict, and generics. Foundation: Function Annotations and Type Hints. Pair with Unit Testing for typed test doubles.
At a glance
| Track | Python Advanced → Typing and Static Analysis |
| Sections | 13 major topics |
| Outline | Use the right-hand TOC to jump |
Topics: Annotations vs runtime behavior · Modern syntax (Python 3.10+) · Variables and attributes · TypedDict — structured dicts · Protocol — structural subtyping · Generics · Literal, Final, and Annotated · Self (Python 3.11+) · … (+5 more)
- Annotations vs runtime behavior
- Modern syntax (Python 3.10+)
- Variables and attributes
TypedDict— structured dictsProtocol— structural subtyping- Generics
Literal,Final, andAnnotatedSelf(Python 3.11+)TypeAliasandNewType- Overloads
TYPE_CHECKINGguard- Configuration for strict projects … and 1 more sections
Annotations vs runtime behavior#
def add(a: int, b: int) -> int:
return a + b
add("hello", "world") # No TypeError at runtime — returns "helloworld"
print(add.__annotations__)
# {'a': <class 'int'>, 'b': <class 'int'>, 'return': <class 'int'>}
| Tool | Role |
|---|---|
mypy |
De facto static checker; strict mode catches most bugs |
pyright / Pylance |
Fast checker bundled with VS Code |
pytype |
Google's inference-heavy checker |
@runtime_checkable |
Opt-in isinstance for Protocol (limited) |
Modern syntax (Python 3.10+)#
Union with |#
# Python 3.10+
def parse(value: str | int) -> int:
return int(value)
# Pre-3.10 equivalent
from typing import Union
def parse_legacy(value: Union[str, int]) -> int:
return int(value)
Optional#
Optional[T] is equivalent to T | None — prefer T | None in new code.
Built-in generics (3.9+)#
def tally(items: list[str]) -> dict[str, int]:
counts: dict[str, int] = {}
for item in items:
counts[item] = counts.get(item, 0) + 1
return counts
Use list, dict, set, tuple directly — no need for typing.List in 3.9+.
Variables and attributes#
from typing import ClassVar
class Config:
DEFAULT_TIMEOUT: ClassVar[float] = 30.0
timeout: float
def __init__(self, timeout: float = DEFAULT_TIMEOUT) -> None:
self.timeout = timeout
name: str = "app"
count: int | None = None
| Annotation target | Stored in |
|---|---|
| Function params / return | func.__annotations__ |
| Module-level variables | __annotations__ on module |
| Class attributes | cls.__annotations__ |
TypedDict — structured dicts#
from typing import TypedDict, Required, NotRequired
class User(TypedDict):
id: int
name: str
email: NotRequired[str] # 3.11+
def greet(user: User) -> str:
return f"Hello, {user['name']}"
data: User = {"id": 1, "name": "Ada"}
Use TypedDict for JSON-shaped dicts instead of untyped dict[str, Any].
Protocol — structural subtyping#
from typing import Protocol
class SupportsClose(Protocol):
def close(self) -> None: ...
def shutdown(resource: SupportsClose) -> None:
resource.close()
class FileWrapper:
def close(self) -> None:
print("closed")
shutdown(FileWrapper()) # OK — has close(), no inheritance required
Protocols enable duck typing with static verification. Use @runtime_checkable only when you need isinstance checks.
Generics#
TypeVar (classic)#
from typing import TypeVar, Generic
T = TypeVar("T")
def first(items: list[T]) -> T:
return items[0]
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()
PEP 695 syntax (Python 3.12+)#
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)
Literal, Final, and Annotated#
from typing import Literal, Final, Annotated
Mode = Literal["r", "w", "a"]
def open_mode(mode: Mode) -> None:
...
MAX_RETRIES: Final = 3
MAX_RETRIES = 4 # mypy error — cannot reassign Final
UserId = Annotated[int, "positive integer user id"]
def fetch(user_id: UserId) -> dict:
...
Annotated attaches metadata for frameworks (Pydantic, FastAPI) without changing the base type.
Self (Python 3.11+)#
from typing import Self
class Builder:
def with_name(self, name: str) -> Self:
self.name = name
return self
def build(self) -> "Product":
...
Self correctly types fluent APIs returning the current class (including subclasses).
TypeAlias and NewType#
from typing import TypeAlias, NewType
JsonDict: TypeAlias = dict[str, "JsonValue"]
JsonValue: TypeAlias = str | int | float | bool | None | JsonDict | list["JsonValue"]
UserId = NewType("UserId", int)
def get_user(uid: UserId) -> dict:
...
raw = 42
get_user(UserId(raw)) # OK
get_user(raw) # mypy error — int is not UserId
NewType creates a distinct type for the checker; at runtime it is still int.
Overloads#
from typing import overload
@overload
def process(data: str) -> str: ...
@overload
def process(data: bytes) -> bytes: ...
def process(data: str | bytes) -> str | bytes:
if isinstance(data, str):
return data.upper()
return data.upper() # bytes.upper()
Overloads document different return types for different input types — only the final implementation runs at runtime.
TYPE_CHECKING guard#
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from collections.abc import Iterable # import only for type checkers
def consume(items: "Iterable[str]") -> None:
for item in items:
print(item)
Avoids circular imports and heavy runtime import costs.
Configuration for strict projects#
# pyproject.toml
[tool.mypy]
python_version = "3.11"
strict = true
warn_return_any = true
disallow_untyped_defs = true
[[tool.mypy.overrides]]
module = "third_party.*"
ignore_missing_imports = true
| Flag | Effect |
|---|---|
strict = true |
Enables full strictness bundle |
disallow_any_generics |
No bare list without type params |
no_implicit_optional |
def f(x: int = None) is an error |
Common patterns in production code#
from collections.abc import Callable, Iterator, Sequence
from pathlib import Path
def read_lines(path: Path) -> Iterator[str]:
with path.open(encoding="utf-8") as f:
yield from f
def apply_twice(fn: Callable[[int], int], value: int) -> int:
return fn(fn(value))
def average(nums: Sequence[float]) -> float:
return sum(nums) / len(nums)
Prefer collections.abc abstract types over concrete list/dict in parameter positions — callers can pass any compatible sequence/mapping.
Interview traps (quick reference)#
| Trap | What goes wrong | Safe approach |
|---|---|---|
| "Type hints enforce types at runtime" | No TypeError without extra tools |
Use mypy/pyright in CI |
list vs List in 3.9+ |
Outdated style | Built-in generics |
Optional[str] meaning |
Forgetting None is valid |
str \| None explicitly |
| Mutable default with typed param | Same runtime bug as untyped | None sentinel pattern |
Any everywhere |
Defeats purpose of hints | Narrow types progressively |
TypedDict with wrong keys |
Runtime KeyError still possible | Validate at boundaries |
Ignoring Protocol |
Over-abstract ABC inheritance | Structural typing for duck types |
Mental model checklist#
- Where are annotations stored at runtime?
- What is the difference between
Union[str, int]andstr | int? - When should you use
Protocolinstead of an ABC? - What does
Selfsolve in class method chaining? - Why use
TYPE_CHECKINGimports? - How does
NewTypediffer fromTypeAlias?
What's next#
| Topic | Page |
|---|---|
| Function-level intro | Function Annotations and Type Hints |
| New 3.10+ syntax | New Syntax and Features |
| Testing typed code | Unit Testing |
| CLI with types | Building Command Line Interfaces |