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 #
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
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
Optionalexists 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.