Skip to main content

Dataclasses, enums & typing basics

Level 2: Level 2: Idiomsmedium12 mindataclassesenumstype-hintsdata-modeling

Model data with @dataclass, name fixed choices with Enum, and add type hints.

Model your data, don't hand-write it

Real backends move records around: a User, an Order, an API payload. If you write those as plain classes, you hand-write an __init__ to store fields, a __repr__ so logs are readable, and an __eq__ so two equal records compare equal. That is boilerplate you will get subtly wrong (forget one field in __eq__ and dedup silently breaks). @dataclass generates all three from a typed field list, so the class is the schema.

Dataclasses: fields become the constructor

Each name: type line under a @dataclass is a field. The decorator reads those fields and writes __init__, __repr__, and __eq__ for you, in field order.

from dataclasses import dataclass

@dataclass
class Point:
    x: int
    y: int

print(Point(1, 2))                  # Point(x=1, y=2)
print(Point(1, 2) == Point(1, 2))   # True

The generated __eq__ compares instances field by field, which is exactly what the Apply exercise leans on: two points built from the same coordinates are equal because their (x, y) tuples are equal.

Check yourself
Point is a plain @dataclass with fields x and y. You write seen = {Point(1, 2)} to start a set of points. What happens?

Which methods the decorator writes depends on the flags you pass it:

Table
The first row is the surprise: defining __eq__ sets __hash__ to None, so a plain dataclass cannot go in a set or be a dict key. frozen=True is what gives it back, which is why value objects are usually frozen.
You writeGeneratesInstances are
@dataclass__init__, __repr__, __eq__mutable, and NOT hashable
@dataclass(frozen=True)the above plus __hash__immutable, so usable as dict keys and in sets
@dataclass(order=True)the above plus __lt__, __le__, __gt__, __ge__sortable, compared field by field
@dataclass(eq=False)__init__ and __repr__ onlycompared by identity, like a plain class
The first row is the surprise: defining __eq__ sets __hash__ to None, so a plain dataclass cannot go in a set or be a dict key. frozen=True is what gives it back, which is why value objects are usually frozen.

Type hints describe intent, they do not enforce it

Check yourself
Point is a @dataclass declaring x: int and y: int. Somebody calls Point('a', 'b'). What happens when that line runs?

Annotations like x: int are metadata. Python does not check them at runtime; passing Point("a", "b") still constructs an object. Their value is for readers and tools like mypy or your editor, which flag mismatches before you run.

def total(prices: list[int]) -> int:
    return sum(prices)

note: int | None = None   # int or None; int | None is the modern Optional

Enums: name a fixed set of choices

When a field has a small closed set of valid values (a status, a color, a role), a bare string invites typos like "gren". An Enum names each choice once. Members carry a .value, and you can look one up by name.

from enum import Enum

class Color(Enum):
    RED = "red"
    GREEN = "green"
    BLUE = "blue"

print(Color.RED.value)   # red
print(Color["RED"])      # Color.RED   (look up by name)

That name lookup is what the Practice driver does: Color[name].value turns "RED" into "red".

Pitfall: mutable default fields

Check yourself
You write items: list = [] as a field inside a @dataclass. What happens?

You cannot give a dataclass field a mutable default like [] or {} directly. Python evaluates the default once, so every instance would share one list. The dataclass machinery blocks it outright:

from dataclasses import dataclass, field

@dataclass
class Cart:
    items: list = []            # ValueError at class creation time

@dataclass
class Cart:
    items: list = field(default_factory=list)   # correct: fresh list per instance

Use field(default_factory=list) (or dict, set) so each instance gets its own container.

Validating and deriving with __post_init__

The generated __init__ only assigns fields, so there is no obvious place to check them or to compute a value from the others. __post_init__ is that place: the dataclass calls it immediately after the constructor finishes.

from dataclasses import dataclass, field

@dataclass
class Order:
    unit_price: float
    quantity: int
    total: float = field(init=False)     # not a constructor argument

    def __post_init__(self):
        if self.quantity < 1:
            raise ValueError("quantity must be at least 1")
        self.total = self.unit_price * self.quantity

print(Order(2.5, 4))       # Order(unit_price=2.5, quantity=4, total=10.0)
print(Order(2.5, 0))       # ValueError: quantity must be at least 1

Two jobs, both worth naming. Validation keeps an invalid object from ever existing, which is far easier to reason about than checking validity at each use site. Derived fields pair __post_init__ with field(init=False), so total is computed rather than passed in and cannot drift out of sync with the values it came from.

One trap: under frozen=True ordinary assignment raises FrozenInstanceError, so a derived field has to be set through the back door:

@dataclass(frozen=True)
class Doubled:
    x: int
    doubled: int = field(init=False)

    def __post_init__(self):
        object.__setattr__(self, "doubled", self.x * 2)   # frozen blocks self.doubled = ...

print(Doubled(21))         # Doubled(x=21, doubled=42)
Check yourself
Point is a @dataclass with fields x and y. What is Point(1, 2) == (1, 2)?

Interview nuance: the generated __eq__ compares field values only when the other object is the same dataclass type; against anything else it returns NotImplemented, so Point(1, 2) == (1, 2) is False, not True. Equality here means "same type and same fields," which is why dataclasses are safe to put in a set or use as dict keys once you add frozen=True (that also generates __hash__).

Worked example (Python)
from dataclasses import dataclass

@dataclass
class Point:
    x: int
    y: int

print(Point(1, 2))                  # Point(x=1, y=2)
print(Point(1, 2) == Point(1, 2))   # True

Apply

Your turn

The task this lesson builds to.

Make Point a @dataclass with two fields, x: int and y: int.

Dataclasses generate __init__ and __eq__ for you, so two points with the same coordinates compare equal. run(1, 2, 1, 2) should return True.

3 hints and 3 automated checks are waiting in the workspace.

Practice

Make it stick

A second problem on the same idea, so it survives past today.

Define a Color enum with three members: RED = "red", GREEN = "green", and BLUE = "blue".

The driver looks up Color[name] and returns its .value, so run("RED") should return "red".

3 hints and 3 automated checks are waiting in the workspace.