Python dataclasses and Type Hints

AI-assistedDrafted with AI assistance and reviewed before publishing; examples are version-pinned. Verify against official docs when in doubt.

Python 3.13 · mypy 1.13

Type hints let editors and CI find mistakes before the code runs, and @dataclass deletes most of the boilerplate from classes that mainly carry data.

Environment

python -m pip install mypy==1.13.0
mypy --version

dataclasses is part of the standard library since Python 3.7.

Annotation syntax

name: str = "Ada"
count: int = 0

def greet(name: str) -> str:
    return f"Hi, {name}"

class Config:
    retries: int
    hosts: list[str]

Containers and unions (built-in generics are preferred on 3.9+):

from collections.abc import Callable, Sequence

def total(values: Sequence[int]) -> int: ...

mapping: dict[str, int]
maybe: int | None = None            # same as Optional[int]
pair: tuple[int, str]
handler: Callable[[str], None]

Reusable shapes get aliases:

type UserId = int                   # 3.12+ type statement
JSONValue = dict[str, "JSONValue"] | list["JSONValue"] | str | int | float | bool | None

Annotations are not enforced at runtime:

x: int = "abc"      # runs fine; mypy reports the error

The interpreter stores them in __annotations__ and otherwise ignores them.

Checking with mypy

mypy src/
mypy --strict src/          # requires annotations everywhere

Typical output:

src/app.py:12: error: Argument 1 to "greet" has incompatible type "int"; expected "str"

Configuration in pyproject.toml:

[tool.mypy]
python_version = "3.13"
strict = true

Editors with Pylance or pyright show the same diagnostics inline, which is where most people actually notice them.

dataclass: classes that hold data

By hand:

class Point:
    def __init__(self, x: float, y: float) -> None:
        self.x = x
        self.y = y

    def __repr__(self) -> str: ...
    def __eq__(self, other: object) -> bool: ...

With a decorator:

from dataclasses import dataclass

@dataclass
class Point:
    x: float
    y: float

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

__init__, __repr__, and __eq__ are generated; field order follows annotation order.

Defaults and default_factory

from dataclasses import dataclass, field

@dataclass
class Team:
    name: str
    members: list[str] = field(default_factory=list)   # new list per instance
    created: str = ""

A mutable default must use default_factory. Writing members: list[str] = [] raises ValueError: mutable default <class 'list'> for field members is not allowed — the decorator catches the bug the interpreter would otherwise allow.

Useful options

@dataclass(order=True)          # generates __lt__ and friends
class Version:
    major: int
    minor: int

@dataclass(frozen=True)         # immutable and hashable
class Settings:
    host: str
    port: int = 8000

@dataclass(slots=True)          # 3.10+, lower memory, no dynamic attributes
class Row:
    id: int

@dataclass
class User:
    name: str
    slug: str = field(init=False)      # not a constructor argument

    def __post_init__(self) -> None:
        self.slug = "-".join(self.name.lower().split())

frozen=True blocks attribute assignment, which makes the instance safe to share and usable as a dict key. To derive a modified copy, use dataclasses.replace(obj, port=9000).

Conversion helpers

from dataclasses import asdict, astuple, replace

asdict(team)          # {'name': ..., 'members': [...]}
astuple(point)        # (1, 2)
replace(point, x=9)   # Point(x=9, y=2)

asdict recurses into nested dataclasses, so it is a convenient bridge to json.dumps.

dataclass versus NamedTuple

Need Choice
Hashable, unpackable, dict key NamedTuple
Mutable with defaults and methods @dataclass
Immutable config object @dataclass(frozen=True)
Two or three fields in an intermediate result plain tuple

Both give you readable field names; the difference is mutability and the tuple protocol.

Common mistakes

1. Assuming annotations validate data

def add(a: int, b: int) -> int: ...
add("1", "2")        # runs, concatenates, no error

For runtime validation use pydantic or explicit checks at the boundary where external data enters.

2. Mutable defaults in a dataclass

Use field(default_factory=list); dict, set, and custom classes have the same problem.

3. Field ordering errors

Fields with defaults must come after fields without, exactly like function parameters.

4. Using Any to silence the checker

Any disables checking for everything it touches. Prefer a precise union; when you must bypass, use a narrow # type: ignore[arg-type] with a comment explaining why.

5. Putting a list inside a frozen dataclass

frozen=True blocks rebinding, not mutation:

@dataclass(frozen=True)
class Settings:
    hosts: list[str]

s = Settings(["a"])
s.hosts.append("b")      # allowed

Use a tuple or frozenset when the contents must be immutable.

6. Annotating without checking

Hints that no tool verifies drift out of date. Run mypy or pyright in CI, otherwise the annotations are only documentation.

7. Using == on floats inside __eq__

Generated __eq__ compares field by field with ==, so float fields behave like floats. That is usually fine, but not for money.

Summary

  • Hints document intent; mypy or pyright enforces them before runtime.
  • @dataclass generates __init__, __repr__, and __eq__; mutable defaults need default_factory.
  • frozen=True for immutable values, slots=True to save memory, order=True for sorting.
  • NamedTuple when you want tuple behavior, dataclass when you want methods and mutability.

Next: iterators, generators, and lazy pipelines.