PythonIntermediate 14 min Lesson 22 of 30

Day 22 — Type Hints and Dataclasses

Say what your functions expect and return, catch mistakes before running, and replace boilerplate classes with dataclasses.

Python · Lesson 22 of 30
0/30 done(0%)

What is it? #

Type hints let you write down what a function expects and returns. def total(items: list[int]) -> int: says it clearly, in the signature, where readers look first.

Python does not enforce them at runtime. They are for humans and for tools. Your editor uses them for autocomplete and warnings; a type checker like mypy uses them to catch mismatches before you run anything.

A dataclass removes the boilerplate from classes that mostly hold data. One decorator gives you __init__, __repr__ and __eq__ generated from the fields you declared.

Together they make data shapes explicit. Instead of passing a dictionary and hoping the right keys exist, you pass an object whose fields are named, typed and checked by your editor.

Think of it like this #

Type hints are labels on a control panel. The machine works without them, but the operator no longer has to guess what the third switch does. Nothing physically stops you flipping the wrong one — the labels just make mistakes far less likely.

Simple example #

An order processing function currently takes a loose dictionary. You add type hints and convert the dictionary into a dataclass, and suddenly your editor tells you when you misspell a field.

Code #

PYTHON
from dataclasses import dataclass, field
from datetime import date
from typing import Optional


@dataclass
class OrderLine:
    sku: str
    quantity: int
    unit_price: float

    @property
    def line_total(self) -> float:
        return round(self.quantity * self.unit_price, 2)


@dataclass
class Order:
    id: str
    customer: str
    lines: list[OrderLine] = field(default_factory=list)
    placed_on: date = field(default_factory=date.today)
    coupon: Optional[str] = None          # str | None also works

    @property
    def total(self) -> float:
        return round(sum(line.line_total for line in self.lines), 2)


def apply_discount(order: Order, percent: float) -> float:
    """Return the payable amount after a percentage discount."""
    if not 0 <= percent <= 100:
        raise ValueError("percent must be between 0 and 100")
    return round(order.total * (1 - percent / 100), 2)


order = Order(id="A-1", customer="Ravi", lines=[
    OrderLine("P-1", 2, 250.0),
    OrderLine("P-2", 1, 499.0),
])

print(order.total)                 # 999.0
print(apply_discount(order, 10))   # 899.1
print(order)                       # Order(id='A-1', customer='Ravi', ...)
print(order == Order(id="A-1", customer="Ravi", lines=order.lines,
                     placed_on=order.placed_on))   # True
BASH
pip install mypy
mypy app.py        # reports type mismatches without running the code

How it works #

@dataclass reads the annotated fields in the class body and generates __init__, __repr__ and __eq__. That is why printing an order shows its fields, and why two orders with equal fields compare as equal.

sku: str is a field declaration, not an assignment. The dataclass decorator uses the annotation to build the constructor parameter.

field(default_factory=list) is the dataclass answer to the mutable default problem from Day 9. A plain lines: list = [] would be shared by every order; default_factory calls list() fresh for each instance.

Optional[str] means "a string or None". In modern Python you can also write str | None, which reads better. It documents that the coupon may legitimately be absent.

-> float on apply_discount states the return type. Combined with order: Order, your editor now knows that order.total exists and that order.totl is a typo, without running anything.

mypy app.py does the full check. It reads the annotations across your files and reports mismatches: passing a string where an int is expected, forgetting that a value can be None, returning the wrong type. None of it changes runtime behaviour.

Real-world use #

Type hints pay for themselves fastest at boundaries: function signatures in shared modules, API request and response shapes, and anything another team calls. Inside a five-line helper they matter less.

Dataclasses replace the endless dictionaries that flow through Python applications. Once a request payload becomes a typed object, misspelled keys become editor errors instead of production KeyErrors at 2am.

Libraries build on this idea. Pydantic and FastAPI use annotations to validate incoming data at runtime and generate API documentation automatically. Learning hints well makes those frameworks feel obvious rather than magical.

Adding types to an existing codebase is normally gradual: start with new code and the most-used modules, run mypy in CI in non-blocking mode, then tighten over time.

Common mistakes #

  • Expecting Python to enforce hints at runtime. It does not — run a checker.
  • Using a mutable default in a dataclass field instead of default_factory.
  • Annotating everything with Any, which looks typed but checks nothing.
  • Forgetting that a value can be None, which is exactly the bug Optional exists to surface.
  • Turning a dataclass into a god object full of business logic. Keep it about data plus small derived properties.

Practice #

Create a Customer dataclass with a name, an email, an optional phone number, and a list of order IDs defaulting to empty. Add a typed function primary_contact(customer: Customer) -> str that returns the phone if present and the email otherwise. Run mypy on it and fix anything it reports.

Quick quiz

  1. 1. Does Python enforce type hints at runtime?

  2. 2. What does `@dataclass` generate for you?

  3. 3. Why use `field(default_factory=list)`?

  4. 4. What does `Optional[str]` mean?

  5. 5. Where do type hints add the most value?

Summary

  • Type hints document expectations and power editor and mypy checks.
  • They are not enforced at runtime — run a type checker to get the benefit.
  • `@dataclass` generates init, repr and equality from declared fields.
  • Use `default_factory` for mutable defaults.
  • Typed objects beat loose dictionaries for data crossing boundaries.