Kodokon kodokon.com

Type hints and dataclasses: typing modern Python seriously

Understand what annotations actually do at runtime, and get the most out of dataclasses with frozen, slots and default_factory.

10 min · 3 questions

Open this lesson in Kodokon

Type 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.

PYTHON
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))
Annotations block nothing: the "incorrect" call succeeds.

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.

PYTHON
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])))
The TypeVar guarantees that dedupe(list[int]) really yields ints.
PYTHON
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"]))
Point is immutable, hashable and has no per-instance __dict__.

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.

Knowledge check

Make sure you remember the key points of this lesson.

  1. What happens at runtime when you call add('py', 'thon') while the function is annotated (a: int, b: int) -> int?
    • Python immediately raises a TypeError
    • The call runs normally: annotations are never checked at runtime
    • Python emits a RuntimeWarning and then runs the call
    • The module refuses to import
  2. Why is items: list[str] = [] forbidden as the default of a dataclass field?
    • Because list[str] is not a valid annotation for a field
    • Because dataclasses only tolerate fields without a default value
    • Because this mutable default would be shared across all instances: the dataclass raises ValueError and requires field(default_factory=list)
    • Because defaults must be set in __post_init__
  3. What does frozen=True do to a dataclass?
    • Instances become immutable and, with the generated equality, hashable - usable as dictionary keys
    • The class can no longer be subclassed
    • The fields become shared class attributes
    • Annotations are checked on every assignment