Dataclasses and type hints
What @dataclass writes for you, the frozen/order/compare switches that change it, and why a type hint is a promise nobody enforces at runtime.
After this lesson you can
- Write a dataclass with a safe mutable default using default_factory
- Choose frozen, order and compare=False to control equality, hashing and sorting
- Explain what type hints do and do not do when the program runs
- Use a Protocol to describe a type by its methods
@dataclass reads the annotated names in a class body and writes the
dunder methods from the previous lesson for you: __init__ (parameters
in field order), __repr__, and __eq__ (comparing all fields, in
order, as a tuple).
from dataclasses import dataclass, field
@dataclass
class Order:
id: int
customer: str
items: list[str] = field(default_factory=list)
Two rules follow from "the defaults go into __init__". Fields with a
default must come after fields without one. And a mutable default such
as [] would be shared by every instance — the mutable-default trap
from lesson 1 — so @dataclass refuses it outright at class creation;
field(default_factory=list) builds a fresh list per instance.
from dataclasses import dataclass, field @dataclassclass Order: id: int customer: str items: list[str] = field(default_factory=list) def run(): a = Order(1, "Nino") b = Order(1, "Nino") a.items.append("tea") try: @dataclass class Broken: items: list = [] refused = "accepted" except ValueError as e: refused = f"ValueError: {e}" try: hash(a) hashing = "hashable" except TypeError as e: hashing = f"TypeError: {e}" return { "repr": repr(a), "equal_before_items": Order(2, "Ana") == Order(2, "Ana"), "a_equals_b": a == b, "b_items": b.items, "shared_default": refused, "hash": hashing, }The switches that matter
eq=Trueis the default, and, exactly as with a hand-written__eq__, it leaves the class unhashable unless it is frozen.frozen=Truemakes assignment to a field raiseFrozenInstanceErrorand generates a__hash__from the compared fields, so instances can be set members and dict keys.order=Trueadds<,<=,>,>=, comparing fields as a tuple in declaration order — the order you write the fields in is the sort order.field(compare=False)leaves a field out of equality, hashing and ordering: useful for a label, a cache or a timestamp that should not decide identity.__post_init__runs after the generated__init__, the place for validation or derived fields; a derived field declared withfield(init=False)is not an__init__parameter and is set there.
from dataclasses import dataclass, field, FrozenInstanceError @dataclass(frozen=True, order=True)class Release: major: int minor: int note: str = field(default="", compare=False) def run(): r = Release(1, 4, "first") try: r.minor = 5 assigned = "allowed" except FrozenInstanceError as e: assigned = f"FrozenInstanceError: {e}" downloads = {r: 120} downloads[Release(1, 4, "again")] += 30 # the same key: note does not count return { "assign": assigned, "equal_despite_note": Release(1, 4, "first") == Release(1, 4, "again"), "downloads": [[f"{k.major}.{k.minor}", n] for k, n in downloads.items()], "ten_after_four": Release(1, 10) > Release(1, 4), "newest": repr(max([Release(1, 10), Release(2, 0), r])), }Type hints are not checks
The list[str] above is a type hint. Python stores hints (in
__annotations__, readable with typing.get_type_hints) and otherwise
ignores them: nothing stops Order("seven", None) at runtime. Hints
are checked before the program runs, by a static checker such as
mypy or pyright, and by your editor.
The vocabulary interviews expect: int | None (what Optional[int]
means), list[int] and dict[str, float] for containers, and
generics, which Python 3.12 can write inline:
def first[T](items: list[T]) -> T:
return items[0]
A Protocol describes a type by the methods it has, not by what it
inherits from — structural typing, the static version of duck typing.
Marked @runtime_checkable, it also works with isinstance, but that
check only confirms the method names exist, never their signatures.
from dataclasses import dataclassfrom typing import Protocol, runtime_checkable, get_type_hints @dataclassclass Point: x: int y: int @runtime_checkableclass Closer(Protocol): def close(self) -> None: ... class File: def close(self) -> None: pass class Door: def close(self, force, slam, loudly): # same name, wrong shape pass def first[T](items: list[T]) -> T: return items[0] def run(): p = Point("seven", None) return { "point": repr(p), "hints": {k: v.__name__ for k, v in get_type_hints(Point).items()}, "file_is_closer": isinstance(File(), Closer), "door_is_closer": isinstance(Door(), Closer), "int_is_closer": isinstance(42, Closer), "first": first(["a", "b"]), }Common mistakes
- Expecting a type hint to reject bad input. It does not; validate in
__post_init__or at the boundary where data enters. - Writing
items: list = []in a dataclass. It fails at class creation with aValueErrorpointing you todefault_factory. - Adding
order=Truewithout thinking about field order. The first declared field decides the sort, the next breaks ties, and so on. - Trusting
isinstance(x, SomeProtocol)to provexis usable. It only checked that attributes with those names exist.
What interviewers ask
- What does
@dataclassgenerate, and when would you not use one?__init__,__repr__,__eq__(plus ordering and hashing on request); a class with real behaviour and invariants may want a hand-written constructor instead. - Why is a regular dataclass unhashable, and how do you make it
hashable? Equality is by value and the fields can change;
frozen=Truemakes it immutable and generates__hash__. - Are type hints enforced? No; they are metadata for static checkers and tools, and a runtime check is something you write (or a library such as pydantic does for you).
Exercise · Pro
An invoice built from dataclass options
Exercises are free in the first 3 lessons of every course. From here on they are part of Pro and Sprint; the lesson and its runnable examples stay open.
Upgrade