Search Tech Journey

Find topics, journeys and posts

6-month learning plan14 / 130
back to blog
pythonbeginner 55m read

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.

🧠SoftwareM01 · Python Foundations· Session 014 of 130 90 min

🎯 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.

You will be able to
  • 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

Types are diagrams the compiler enforces
🌍 Real world

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.

💻 Code world

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

Which typing tool for which job
  • 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

  1. 2014
    PEP 484 · Type Hints
    Guido writes the type-hint syntax. Python 3.5 ships it in the `typing` module.
  2. 2016
    mypy 0.4
    Dropbox's Jukka Lehtosalo builds the static checker. Instagram adopts it and cuts errors in half.
  3. 2018
    dataclasses in stdlib (3.7)
    Boilerplate-free classes with generated dunder methods. Replaces `namedtuple` and half of `attrs`.
  4. 2019
    Pydantic v1
    Runtime validation with hint-driven syntax. Powers FastAPI, which explodes in popularity.
  5. 2023
    Pydantic v2 (Rust core)
    5-50× faster than v1. Now the default for LLM tool-calling, config, and API boundaries.
  6. 2024+
    typing as spec
    Types 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

Primitives — int, str, bool, float, bytes, None
Just Python types. Union with `|`: `int | None`. Since 3.10.
syntax
Generics — list[X], dict[K, V], tuple[X, ...]
Since 3.9 you can lowercase them. `dict[str, list[int]]` — reads left to right.
collections
Literal, Final, ClassVar, TypeAlias
`Literal['red','blue']` restricts values. `Final` forbids reassignment. Nice for enums-lite and constants.
constraints
TypedDict — schema for dicts
When JSON-ish data must stay a dict. Structural typing without instantiating a class.
dicts
Protocol — structural interfaces
`class Readable(Protocol): def read(self) -> bytes: ...`. Duck typing but type-checked.
abstracts
dataclasses — value objects
`@dataclass(frozen=True, slots=True)` — free init/repr/eq, immutable, memory-efficient.
classes
pydantic BaseModel — runtime validation
Same hint syntax; parses, validates, coerces at runtime. Serialises to JSON schema for free.
boundaries

When to reach for what

plain dict

Throwaway scripts

  • Zero ceremony
  • Zero safety
  • No IDE autocomplete
  • Fine for a 20-line pipeline
  • Never in a library
TypedDict

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
dataclass

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
pydantic BaseModel

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

1
uv add --dev mypy

Install as a dev dependency. Not needed at runtime, only during development and CI.

2
Add [tool.mypy] to pyproject.toml

Enable strict-ish mode: `strict = true` or a curated subset (see hands-on).

3
mypy src/

Run against your source tree. First run typically has 10-100 errors on a legacy codebase — that's normal.

4
Fix or `# type: ignore[reason]`

Fix genuine bugs. Escape hatch: `# type: ignore[assignment]` with a specific error code — never bare.

Mental model


Common misconception
✗ What most people think

"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."

✓ What is actually true

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).

Why the myth is so sticky

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.

Prove it to yourself

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 either
From first principles
Start with the question

Why 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.

  1. 1
    A 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
  2. 2
    A 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
  3. 3
    A 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
  4. 4
    But 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
  5. 5
    So 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

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.

Mental modelTypes are a proof about code, validation is a check on data

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 handle None, which is the most common production crash in Python.
  • Use frozen=True by 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.
🔔 Fires when you see

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.

The tradeoff

How much type discipline does a codebase buy — none, gradual hints checked in CI, or runtime-validated models everywhere?

No annotations
+ you gain maximum flexibility and no tooling; duck typing lets you pass anything with the right methods, which is genuinely the fastest way to write exploratory code
− you pay types live only in docstrings and reviewers' heads; refactors become guesswork; IDE completion degrades and every signature question requires reading the implementation
pick when notebooks, throwaway analysis, and code with a lifetime measured in hours — the exploratory phase, honestly labelled
Gradual hints + mypy/pyright in CI
+ you gain whole classes of bug caught before execution at zero runtime cost, plus signatures that document themselves and IDEs that actually help; adoptable file by file
− you pay a real learning curve at the edges (generics, variance, protocols); third-party stubs are uneven; and a mostly-typed codebase invites Any escape hatches that quietly erase the guarantee
pick when the code will be maintained by more than one person for more than a few months — which is the default for anything in a repo
Runtime validation (pydantic / attrs validators)
+ you gain guarantees enforced against real data, with coercion, clear error messages, and JSON schema generation — the only option that catches upstream schema drift at the point of entry
− you pay per-object construction cost, which is material in a hot loop over millions of rows; a second type vocabulary alongside the static one; and it can tempt you into validating internal calls that were already proven safe
pick when the data crosses a trust boundary — API request bodies, config files, message payloads, third-party responses
What a senior engineer actually does

The 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.

#!/usr/bin/env bash# type_demo.sh from dict soup to typed value objects.set -euo pipefail WORK_DIR="$HOME/projects/learning/s014/orders"log() { printf "\033[1;36m %s\033[0m\n" "$*"; } log "1/6 Fresh project"rm -rf "$WORK_DIR" && mkdir -p "$WORK_DIR/src/orders" "$WORK_DIR/tests"cd "$WORK_DIR" log

What each block does

Anatomy of the codebase

domain.py · @dataclass(frozen=True, slots=True) LineItem
Immutable value object. `frozen` = no accidental mutation. `slots` = smaller memory footprint + faster attribute access. Perfect for values that get created a lot.
value
domain.py · OrderStatus(StrEnum)
An enum that IS its string value. Serialises cleanly to JSON, works in `Literal[...]` positions, IDEs know the allowed values.
enum
domain.py · order_total → Decimal
Money never uses float. Decimal is exact. Type hint documents the invariant.
money
api.py · Field(gt=0, le=1000)
Pydantic v2's Field constraints. Runtime-checked. quantity > 0 and ≤ 1000 without writing a single validator.
constraints
api.py · @field_validator('items')
Cross-field / complex rules that constraints can't express (like uniqueness). Runs after individual fields validate.
rules
api.py · to_domain / from_domain
Explicit conversion between the outside-world (pydantic) shape and the inside-world (dataclass) shape. Keeps concerns separated.
boundary
pyproject · [tool.mypy]
`strict=true` turns on: no implicit Any, no untyped defs, warn on unused ignores. The overrides block relaxes tests where fixtures make full typing painful.
config
Try itFeel the difference between static hints and runtime validation

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!
💡 Hint · Try each of the three ‘breakages’ below. Notice: mypy catches (1) at check-time with no code running; pydantic catches (2) at runtime with a clean error; nothing catches (3) — dataclasses don't validate — which is exactly why we don't accept raw JSON directly into a dataclass.

(d) Production reality · 15 min

War story Dropbox · engineering blog· 2019millions of lines of Python
🔥 What broke

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.

🧯 The fix
Gradual typing: annotate leaf modules first, then middle layers, using # 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.
🎓 Lesson to steal
Types don't need to be all-or-nothing. Start with new modules strict; ratchet existing modules over quarters. The class of bug that goes away is disproportionately large.
Post-mortem
War story OpenAI / Anthropic style · LLM tool calling· 2024every serious LLM app
🔥 What broke
Teams shipped LLM apps that took the model's function-call output as raw dicts. Model hallucinates a field name; app crashes at data["custmer_id"]. Or the model returns "1" instead of 1; app breaks 3 layers deep with a cryptic error.
🧯 The fix
Every LLM tool schema is now a pydantic 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.
🎓 Lesson to steal
Pydantic is the lingua franca of LLM tool-calling. Any untrusted structured input (LLM output, webhook, form, CSV upload) belongs behind a BaseModel. Get the schema-derived error message to the source and let it retry.
War story Common failure · money as floatperiodic, embarrassing
🔥 What broke
A billing script totals invoice lines with 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.
🧯 The fix
Type money as 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.
🎓 Lesson to steal
Types are how you make invariants machine-checkable. "Money is Decimal, not float" is an invariant you either write down as a type (and enforce with mypy + pydantic) or discover during a customer refund.

Where this shows up in the rest of the plan

Types thread through the whole learning plan
S040 · FastAPI
Pydantic models ARE the request/response schema; FastAPI generates OpenAPI from them.
S054 · SQLModel / SQLAlchemy 2.x
Mapped[T] annotations feed the ORM; the same class is your DB row and your type.
S078 · ML feature stores
Typed feature schemas prevent shape/dtype mismatches at train vs serve time.
S105 · LLM tool calling
Every tool signature is a pydantic BaseModel; JSON-schema is auto-derived.
S110 · Structured outputs (OpenAI / Anthropic)
Model → JSON → BaseModel.model_validate → typed object. Zero KeyError incidents.
S121 · Config as code
pydantic-settings loads env vars into a typed Settings model; mypy checks reads.

(e) Recall + stretch · 10 min

Recall — click each to reveal · click to reveal
★ = stretch question

Explain-out-loud test

  1. What is the difference between a type hint and a runtime validator? (one sentence, one example)
  2. When would you use a dataclass and when a pydantic BaseModel? (name the boundary)
  3. 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.