Understand what annotations actually do at runtime, and get the most out of dataclasses with frozen, slots and default_factory.
Open this lesson in KodokonType annotations are metadata, not constraints: the interpreter stores them in __annotations__ and never checks them at runtime. Checking is the job of a static analyzer such as mypy or pyright, ahead of execution. The direct consequence: a badly typed program runs without complaint - and that's by design. Gradual typing lets you annotate an existing codebase little by little without breaking anything.
from typing import get_type_hints
def add(a: int, b: int) -> int:
return a + b
print(add("py", "thon"))
print(add.__annotations__)
print(get_type_hints(add))Since Python 3.9, built-in types are directly generic: write list[int] or dict[str, float] without importing typing.List. Unions are written int | None since 3.10. To tie the input type to the output type, use a TypeVar - Python 3.12 even offers the compact syntax def first[T](items: list[T]) -> T. A point that's often overlooked: from __future__ import annotations makes every annotation lazy (stored as strings), which allows references to classes defined further down and lowers the cost at import time.
from collections.abc import Iterable, Iterator
from typing import TypeVar
T = TypeVar("T")
def dedupe(items: Iterable[T]) -> Iterator[T]:
seen: set[T] = set()
for item in items:
if item not in seen:
seen.add(item)
yield item
print(list(dedupe([3, 1, 3, 2, 1])))from dataclasses import dataclass, field
@dataclass(frozen=True, slots=True)
class Point:
x: float
y: float
@dataclass
class Basket:
items: list[str] = field(default_factory=list)
total: int = 0
def __post_init__(self) -> None:
self.total = len(self.items)
point = Point(1.0, 2.0)
print(point, hash(point))
print(Basket(["book", "pen"]))Three parameters turn @dataclass into a production-grade tool. frozen=True makes instances immutable and, combined with the generated equality, hashable - usable as dictionary keys. slots=True (Python 3.10+) removes the per-instance __dict__: lower memory, faster attribute access, and any unexpected attribute assignment fails. Finally kw_only=True forces explicit keyword calls. __post_init__ is where invariants and derived fields go. For a simple immutable aggregate, NamedTuple still makes sense; as soon as there's behavior or validation, prefer the dataclass.
add('py', 'thon') while the function is annotated (a: int, b: int) -> int?items: list[str] = [] forbidden as the default of a dataclass field?list[str] is not a valid annotation for a fieldfrozen=True do to a dataclass?