Python – Dataclasses & Type Hints

August 1, 20264 min readUpdated 8/20/2026

Most classes exist to hold a few fields. @dataclass writes the constructor, the repr and the equality for you, and type hints let a checker catch mistakes before the program runs. Together they remove most of the boilerplate from the previous two posts.

The problem

Here is the class from Class, written out in full:

class Account:
    def __init__(self, number, balance):
        self.number = number
        self.balance = balance

    def __repr__(self):
        return f"Account({self.number!r}, {self.balance!r})"

    def __eq__(self, other):
        if not isinstance(other, Account):
            return NotImplemented
        return (self.number, self.balance) == (other.number, other.balance)

print(Account("1001", 1250) == Account("1001", 1250))   # Output: True

Thirteen lines, of which two carry information — the field names. Every one of the others is mechanical, and every one is a place to make a typo.

@dataclass

from dataclasses import dataclass

@dataclass
class Account:
    number: str
    balance: float = 0.0

a = Account("1001", 1250.0)

print(a)                                    # Output: Account(number='1001', balance=1250.0)
print(a.balance)                            # Output: 1250.0
print(a == Account("1001", 1250.0))         # Output: True
print(Account("1001"))                      # Output: Account(number='1001', balance=0.0)

Four lines replace thirteen and do slightly more. The decorator reads the annotated fields and generates __init__, __repr__ and __eq__ from them, in order, with defaults working exactly as they do in a function.

The annotations are load-bearing here — that is how the decorator finds the fields. A field with no annotation is invisible to it. This is the one place a type hint changes behaviour rather than just documenting it.

frozen, and the mutable default

from dataclasses import dataclass, field

@dataclass(frozen=True)
class Transaction:
    id: int
    amount: float
    tags: list[str] = field(default_factory=list)

t = Transaction(1, 100.0)
print(t.tags)          # Output: []

try:
    t.amount = 999
except Exception as error:
    print(type(error).__name__)     # Output: FrozenInstanceError

frozen=True makes instances immutable, which also makes them hashable — so they can go in a set or be a dict key. Use it for anything that represents a fact rather than a thing that changes: a transaction has happened and will not un-happen.

field(default_factory=list) is how you get a mutable default safely. A plain tags: list[str] = [] is the shared-default bug from Functions, and dataclasses refuse it outright with a ValueError rather than letting you ship it.

Validation with __post_init__

from dataclasses import dataclass

@dataclass
class Account:
    number: str
    balance: float = 0.0

    def __post_init__(self):
        if self.balance < 0:
            raise ValueError("balance cannot be negative")
        self.number = self.number.strip()

print(Account("  1001  ").number)     # Output: 1001

try:
    Account("1001", -5)
except ValueError as error:
    print(error)                      # Output: balance cannot be negative

The generated __init__ calls __post_init__ after setting the fields, which is where validation and normalisation go. Doing it here means an invalid instance cannot exist anywhere in the program — much stronger than checking at each call site.

In real code

The bank app's model layer is dataclasses throughout. This is its transaction record:

@dataclass(frozen=True)
class Transaction:
    id: int
    account_id: int
    type: TransactionType
    amount: Decimal
    balance_after: Decimal
    timestamp: datetime
    description: str

    @property
    def signed_amount(self) -> Decimal:
        return self.type.signed(self.amount)

Seven fields, frozen because a statement line never changes once written, and a @property alongside them — a dataclass is a normal class and takes normal methods.

Type hints

Outside a dataclass, hints do nothing at runtime. They are for readers and for checkers:

def apply_fee(balance: float, fee: float = 2.50) -> float:
    return balance - fee

def find(number: str) -> str | None:
    return None if number == "missing" else "Checking"

print(apply_fee(100.0))       # Output: 97.5
print(find("missing"))        # Output: None

The syntax worth knowing is short: name: type for a parameter or variable, -> type for the return, | for "either" (3.10 onwards), and builtin generics for containers — list[str], dict[str, float], tuple[int, ...]. The old typing.List spellings are obsolete since 3.9.

str | None is the one that repays the effort. It is the difference between a value you can use and one you must check first, and it is the single most common source of AttributeError: 'NoneType' object has no attribute ....

What a checker adds

Nothing checks hints unless you run a checker. Install mypy and it reads the annotations you already wrote:

pip install mypy
mypy bank/

# bank/services.py:42: error: Argument 1 to "apply_fee" has
#     incompatible type "str"; expected "float"

That is the value proposition: a class of bug that would otherwise surface at runtime, in production, caught before the program starts. Editors use the same information for completion and inline warnings, which is a benefit you get without running anything.

The pragmatic approach is to hint function signatures and dataclass fields, and not to bother inside function bodies where the types are obvious. Hints on the boundaries are where nearly all the value is.

Comparing and sorting dataclasses

order=True generates the comparison operators as well, using the fields in declaration order — the same rule tuples follow:

from dataclasses import dataclass, field, asdict

@dataclass(order=True)
class Account:
    kind: str
    balance: float

accounts = [Account("Savings", 8400.5), Account("Checking", 1250.0)]

for a in sorted(accounts):
    print(a.kind)

# Output: Checking
# Output: Savings

print(asdict(accounts[0]))     # Output: {'kind': 'Savings', 'balance': 8400.5}

Field order decides sort order, so put the field you sort by first — or pass field(compare=False) on the ones that should be ignored.

asdict() converts an instance to a plain dict, recursively, which is how a dataclass becomes JSON in one step. Its partner replace(obj, balance=0) returns a modified copy, which is what you use on a frozen instance instead of assigning to it.

When not to use a dataclass

If the class is mostly behaviour with one or two attributes, a plain class is clearer — the dataclass machinery buys you nothing. If it is an immutable record with no methods at all, a NamedTuple is lighter and unpacks. And if you need validation and parsing of external data, Pydantic does at runtime what mypy does statically.

Next: Exception Handling.