S014 · Type Hints, mypy, dataclasses & pydantic
Modern Python that scales past 1000 lines: gradual typing with mypy, immutable value objects with dataclasses, and runtime-validated boundaries with pydantic v2.
🎯 Refactor a dict-shaped codebase into typed dataclasses and pydantic models, run mypy in strict mode without warnings, and know exactly when to reach for each.
Why this session exists
Python's dynamic typing is a superpower for scripts and a footgun for anything bigger than 500 lines. You'll ship a bug where user_id is sometimes a string and sometimes an int; three months later a stranger tries to add a feature, spends 20 minutes tracing dict["user"]["profile"]["prefs"] through five files, and gives up. Types are how you make a codebase teachable to strangers (including future-you). This session teaches you the three flavours modern Python offers — pure hints for the compiler, dataclasses for internal value objects, and pydantic for the fuzzy boundary where data enters your system.
- Read a signature like `def f(xs: list[User]) -> dict[str, int] | None:` fluently.
- Add type hints to an untyped function and run mypy without noise.
- Choose between `dict`, `TypedDict`, `dataclass`, `pydantic.BaseModel`, and `NamedTuple` — and defend the choice.
- Validate incoming JSON with pydantic v2 and get precise error messages for free.
- Enable strict-ish mypy on a real project without drowning in errors.
Prerequisites
- S012 · Modules & Packaging — you'll add mypy/pydantic to a pyproject.toml.
- S013 · pytest — you'll type-check your test suite too.
- Comfortable writing classes, list/dict comprehensions, and using
dict[str, Any]in anger.
(a) Intuition · 5 min
Imagine a factory floor. Every conveyor belt is labelled: bolts here, washers here, finished assemblies out this end. If someone dumps screws on the bolt conveyor, a machine beeps immediately — long before the wrong part reaches the finished-assembly line.
Without labels, the same mistake reaches the customer three weeks later. The labels are cheap. The rework isn't.
Type hints are those labels. def area(r: float) -> float: says "this belt carries floats in, floats out." mypy is the machine that beeps when someone passes a string. Runtime is unchanged — Python still ignores hints — but your IDE, your linter, and your CI now have a spec they can enforce.
The trick is gradual: you don't have to type everything. Type the boundaries (function signatures, class attributes, public APIs). Leave the two-line comprehensions alone. Types earn their keep on the interfaces, not the internals.
Three flavours, three jobs
- Type hints + mypy — a static check that runs on your codebase. Zero runtime cost, catches whole-classes of bugs before you run anything.
- dataclasses — a stdlib decorator that generates __init__, __repr__, __eq__ for a value object. For data you own and trust internally.
- pydantic — runtime validation of untrusted input. For the fuzzy boundary: JSON from an HTTP request, YAML config, LLM output. It parses AND validates AND coerces.
A quick history
- 2014PEP 484 · Type HintsGuido writes the type-hint syntax. Python 3.5 ships it in the `typing` module.
- 2016mypy 0.4Dropbox's Jukka Lehtosalo builds the static checker. Instagram adopts it and cuts errors in half.
- 2018dataclasses in stdlib (3.7)Boilerplate-free classes with generated dunder methods. Replaces `namedtuple` and half of `attrs`.
- 2019Pydantic v1Runtime validation with hint-driven syntax. Powers FastAPI, which explodes in popularity.
- 2023Pydantic v2 (Rust core)5-50× faster than v1. Now the default for LLM tool-calling, config, and API boundaries.
- 2024+typing as specTypes drive JSON-schema, OpenAPI, LLM function signatures, DB schemas. The `Model` is the truth.
(b) Visual walkthrough · 15 min
How the pieces fit — a request/response lifecycle
The typing tools you'll actually use
The Python typing stack, layered
When to reach for what
Throwaway scripts
- Zero ceremony
- Zero safety
- No IDE autocomplete
- Fine for a 20-line pipeline
- Never in a library
Dict shape matters, but stays a dict
- Static-only (mypy)
- No runtime cost
- Interop with JSON APIs
- Compose with `NotRequired`
- Great for JSON-ish payloads you don't own
Internal value objects
- `__init__`, `__repr__`, `__eq__` free
- `frozen=True` for hashable/immutable
- `slots=True` cuts memory + speeds attribute access
- No validation at runtime
- Use for domain entities you own
Untrusted boundaries
- Parses AND validates AND coerces
- Field-level errors
- JSON-schema for free (OpenAPI, LLM tools)
- Rust-fast in v2
- Use for HTTP, config, LLM output, DB seed data
The mypy workflow in 4 steps
Install as a dev dependency. Not needed at runtime, only during development and CI.
Enable strict-ish mode: `strict = true` or a curated subset (see hands-on).
Run against your source tree. First run typically has 10-100 errors on a legacy codebase — that's normal.
Fix genuine bugs. Escape hatch: `# type: ignore[assignment]` with a specific error code — never bare.
Mental model
"Type hints are enforced at runtime. If I annotate def f(x: int) and pass a string, Python will raise a TypeError — that's the point of adding them."
Annotations are stored metadata and nothing else. CPython evaluates them (or, with from __future__ import annotations, doesn't even do that), stashes them in __annotations__, and never checks them. Enforcement requires an external checker (mypy, pyright) or an explicit runtime validator (pydantic, beartype).
Because the syntax is identical to languages where it is enforced, and because in a well-typed codebase the checker catches your mistakes before you run anything — so it feels like the runtime did it. The gap is invisible until data arrives from outside your program. A function annotated -> dict[str, int] that parses JSON will happily return strings, because mypy checked your code and JSON came from the network. This is exactly why the boundary layer needs real validation rather than annotations: static typing proves your code is internally consistent, it says nothing about whether the world agreed.
The annotation is data, not a check:
def f(x: int) -> int:
return x * 2
print(f('ab')) # 'abab' - no error, mypy would flag it
print(f.__annotations__) # {'x': <class 'int'>, 'return': <class 'int'>}
from dataclasses import dataclass
@dataclass
class Row: id: int
print(Row(id='oops')) # Row(id='oops') - dataclasses don't validate eitherWhy does @dataclass(frozen=True) give you hashability, while a plain dataclass gives you __eq__ but sets __hash__ to None — making it unhashable even though a plain class was hashable? Derive why defining equality must destroy the default hash.
- 1A hash table requires that equal objects have equal hashes, or lookup breaks: you would store under one bucket and search another.forced by · the hash decides the bucket, and equality decides the match within it
- 2A plain object's default equality is identity, and its default hash is derived from identity. Those are trivially consistent — two objects are equal only when they are the same object.forced by · identity is stable for an object's lifetime, so both derived properties are stable
- 3A dataclass replaces equality with structural comparison: two distinct objects with the same field values are now equal.forced by · value semantics is the entire reason to use a dataclass
- 4But the inherited identity-based hash would give those two equal objects different hashes, violating the invariant — a silent, near-undebuggable failure where a dict lookup misses a key that compares equal to one it contains.forced by · the two defaults are no longer derived from the same thing
- 5So the hash must be recomputed from the same fields as equality. That is only safe if those fields cannot change — a mutable field would change the hash after insertion and orphan the entry.forced by · nothing re-indexes a dict when an object mutates; the table is never notified
Therefore Python takes the only safe route: define __eq__ and set __hash__ = None, making the mutable dataclass unhashable by construction. Add frozen=True — which blocks __setattr__ — and the field-based hash becomes safe, so it is generated.
And note what this predicts: writing your own __eq__ on any class silently drops __hash__ unless you define it too, so instances suddenly stop working as dict keys or set members. It also predicts that frozen=True is only shallow — a frozen dataclass holding a list field is still unhashable at hash time, exactly as hash((1, [2])) fails.
Draw a boundary around your program. Inside, type hints plus a checker give you a proof, at zero runtime cost, that the pieces fit together — every call site matches every signature, no None reaches a method that can't handle it. Nothing is checked while running because nothing needs to be; it was proven beforehand.
At the boundary — JSON, config files, database rows, API responses, user input — no proof is possible, because the data arrives at runtime from something you don't control. There you need actual validation that fails loudly. Confusing the two produces both classic mistakes: annotating everything and still crashing on bad input, or validating internally in every function and paying for checks the type system already proved.
- Annotate function boundaries and dataclass fields; skip obvious locals. The payoff is in signatures, where callers read them.
Optional[X]is the highest-value annotation you can write — it forces the checker to make you handleNone, which is the most common production crash in Python.- Use
frozen=Trueby default for records: hashable, safe to share across threads, and impossible to mutate accidentally three layers away. - Parse, don't validate: convert untrusted input into a typed object once at the boundary, then let the type system carry that guarantee inward instead of re-checking.
Fire this model the moment you see: AttributeError: 'NoneType' object has no attribute ... · unhashable type on your own class · a mutable default in a dataclass field · Any spreading through a module · a function whose docstring describes types the signature doesn't.
How much type discipline does a codebase buy — none, gradual hints checked in CI, or runtime-validated models everywhere?
Any escape hatches that quietly erase the guaranteeThe combination is the answer, applied by layer rather than uniformly: validated models at the boundary, plain typed dataclasses inside, checker in CI over both. That gets you loud failures where the world is unpredictable and free proofs where it isn't.
The scale-specific caveat for data work: per-row validation of a hundred million rows is the wrong shape entirely. Validate the schema once and assert on aggregates — null rates, distinct counts, range bounds — rather than constructing a model per row. Types are for code correctness; column-level assertions are for data correctness, and the volume is what decides which tool applies.
(c) Hands-on · 25 min
You'll refactor an untyped "orders" module into a typed, dataclass-based core with a pydantic boundary — then run mypy and see it prove correctness. Save as type_demo.sh.
What each block does
Anatomy of the codebase
Break the code three ways and see who complains:
# 1. Static: mypy catches this without running anything
from decimal import Decimal
from orders import LineItem
LineItem(sku="X", quantity="two", unit_price=Decimal("1.00")) # str where int expected
# 2. Runtime boundary: pydantic complains cleanly
from orders import CreateOrderRequest
CreateOrderRequest.model_validate({
"customer_id": "not-a-uuid",
"items": [{"sku": "X", "quantity": -1, "unit_price": "0.00"}],
}) # ValidationError with 3 sub-errors
# 3. Internal dataclass: NO runtime check — silently accepts wrong type
LineItem(sku=42, quantity=1, unit_price=1.5) # runs; sku is now an int; bug!(d) Production reality · 15 min
Dropbox migrated their entire Python monolith to type-annotate over 4 million lines. Before mypy, they had frequent production incidents caused by an int reaching a code path that expected a str, or a None where a value was assumed.
Bugs like "the sync engine occasionally throws AttributeError when a file's mtime is None" survived for months because they only triggered on 0.01% of accounts.
# type: ignore where callers weren't ready. Ship mypy in CI as a warning, then blocker. After 3 years, ~90% of the codebase was strict-typed and the entire class of "Optional not handled" incidents disappeared from the incident tracker.data["custmer_id"]. Or the model returns "1" instead of 1; app breaks 3 layers deep with a cryptic error.BaseModel. The schema is dumped to JSON-schema and sent to the model; the model's output is fed to Model.model_validate(...). Field-level errors are sent back to the model as a retry ("your customer_id field is missing"), and after 1-2 retries the model self-corrects. Zero cryptic KeyError incidents.sum(prices) where prices are floats. Customer sees $19.9999999998 on their invoice. Support ticket. Or worse: 0.1 + 0.2 == 0.3 is False, so a "should be free" line item bills 1 cent.Decimal everywhere. Pydantic's Field(max_digits, decimal_places) enforces it at the boundary; mypy enforces it internally. Never let a float enter a money code path. Optional stricter path: use a Money(NewType('Money', Decimal)) so mypy separates money from any other Decimal.Where this shows up in the rest of the plan
(e) Recall + stretch · 10 min
Explain-out-loud test
- What is the difference between a type hint and a runtime validator? (one sentence, one example)
- When would you use a dataclass and when a pydantic BaseModel? (name the boundary)
- What is one class of bug that types make impossible? (concrete example)
What comes next
Hub: The 6-Month Learning Plan
Part of a 130-session evergreen learning series. Session structure: intuition → visual → hands-on → production war stories → recall. Duration: 90 minutes.