← Back to list

I Started Treating Python Type Hints as Architecture, Not Documentation

The moment I stopped writing type hints for readers and started writing them for the compiler in my head.

Maximilian Oliver in Python in Plain English · 2026-08-03 17:41 · 1 claps · 10.2 min read paywalled
#python #python-programming #coding #programming #python-tips
Open on Medium ↗
Wiki topics: 💻 · Programming 🏛️ · Architecture

I Started Treating Python Type Hints as Architecture, Not Documentation

The moment I stopped writing type hints for readers and started writing them for the compiler in my head.

For years I treated type hints the way most Python developers do: as a courtesy. A little -> str here, a List[int] there, mostly so my editor could autocomplete things and so future-me wouldn't have to guess what a function returned. I thought of them as comments that happened to be checked by a linter. Nice to have. Not load-bearing.

That changed on a project where a “simple” refactor — renaming one field on a shared data model — took down three services in production. Not because the code was badly written. Because nothing in the codebase actually knew what shape that data was supposed to have at each boundary. Dictionaries flowed from service to service like water finding cracks. Everything compiled, if you can call python file.py running without a SyntaxError compiling. Nothing was actually checked.

That was the day I stopped treating type hints as documentation and started treating them as architecture — the thing that defines the shape of the system, not a description of it.

This is the long version of that shift: what changed in how I structure Python projects, the tools I now consider non-negotiable and the patterns that turned my type hints from decoration into a load-bearing wall.

1. The Dictionary Problem: Where Untyped Python Actually Hurts

Before I get into solutions, I want to be precise about the disease because “just use type hints” is advice everyone has heard and mostly ignored.

The failure mode isn’t usually inside a single function. It’s at the boundaries — where data crosses from one part of the system into another. A JSON payload becomes a dict. A dict gets passed into three functions. Somewhere in function four, someone accesses payload["user_id"], except upstream renamed it to payload["userId"] two weeks ago and nothing complained until a customer did.

Here’s the shape of the problem, made minimal:

def get_user_summary(payload: dict) -> dict:
    user_id = payload["user_id"]
    name = payload.get("name", "Unknown")
    email = payload["email"]
    return {
        "id": user_id,
        "display_name": name,
        "contact": email,
    }

def notify_user(summary: dict) -> None:
    # somewhere downstream, someone assumes "contact" is always an email
    send_email(summary["contact"])

def render_profile_card(summary: dict) -> str:
    # someone else assumes "display_name" is never None
    return f"<div class='profile'>{summary['display_name'].upper()}</div>"

Every one of these functions “works.” Every one of them will explode the moment the shape of payload or summary drifts even slightly and the explosion happens far away from the mistake that caused it. dict is not a type in any meaningful sense here — it's the absence of a type, wearing a type's clothing.

This is the pattern I started hunting for across every codebase I touched: places where dict, Any or an untyped **kwargs was standing in for what was actually a real, nameable concept in the domain.

2. Replacing Dictionaries With Real Shapes

The first architectural move is embarrassingly simple and that’s exactly why it’s easy to skip: give every meaningful shape of data an actual type.

I reach for dataclasses as the default, TypedDict when I'm modeling something that genuinely originates as JSON and I don't want object overhead and pydantic models when the data crosses a trust boundary (API input, config files, environment variables) and needs runtime validation, not just static checking.

Here’s the same example, rebuilt around real shapes:

from dataclasses import dataclass
from typing import Optional
@dataclass(frozen=True)
class UserPayload:
    user_id: str
    email: str
    name: Optional[str] = None

@dataclass(frozen=True)
class UserSummary:
    id: str
    display_name: str
    contact: str

def get_user_summary(payload: UserPayload) -> UserSummary:
    return UserSummary(
        id=payload.user_id,
        display_name=payload.name or "Unknown",
        contact=payload.email,
    )

def notify_user(summary: UserSummary) -> None:
    send_email(summary.contact)

def render_profile_card(summary: UserSummary) -> str:
    return f"<div class='profile'>{summary.display_name.upper()}</div>"

Nothing about this is clever. That’s the point. The moment UserPayload changes shape, every call site that constructs one breaks at the construction site, not three functions downstream in a place that has no idea what changed. frozen=True buys me immutability, which turns out to matter more than I expected — more on that in a moment.

The architectural shift here is subtle but real: the type definitions become the map of your domain. If you can look at a directory of dataclasses and immediately understand what concepts exist in your system and how they relate, your types are doing architectural work. If your domain concepts only exist implicitly, scattered across dict key accesses, your architecture is invisible — which means it can’t be reviewed, refactored safely or reasoned about by anyone new to the code.

3. Making Illegal States Unrepresentable

Once I started treating types as architecture, the next shift was realizing that a good type doesn’t just describe valid data — it makes invalid data impossible to construct in the first place.

I used to write things like this:

@dataclass
class Order:
    status: str  # "pending", "shipped", "cancelled", ...
    tracking_number: Optional[str]
    cancellation_reason: Optional[str]

The problem: nothing stops you from creating an order that’s "cancelled" with a tracking_number set or "shipped" with no tracking number at all. The type says "these fields exist." It says nothing about which combinations are legal. That logic ends up scattered across if statements throughout the codebase and it's exactly the kind of logic that rots when someone adds a new status and forgets to update every check.

Here’s the version that makes the illegal states impossible to construct:

from dataclasses import dataclass
from typing import Union
@dataclass(frozen=True)
class Pending:
    pass

@dataclass(frozen=True)
class Shipped:
    tracking_number: str

@dataclass(frozen=True)
class Cancelled:
    reason: str

OrderStatus = Union[Pending, Shipped, Cancelled]

@dataclass(frozen=True)
class Order:
    order_id: str
    status: OrderStatus

def describe_order(order: Order) -> str:
    match order.status:
        case Pending():
            return "Order is pending."
        case Shipped(tracking_number=tn):
            return f"Order shipped, tracking: {tn}"
        case Cancelled(reason=reason):
            return f"Order cancelled: {reason}"
    raise AssertionError("unreachable")

Now it is structurally impossible to create a Shipped order without a tracking number or a Cancelled order without a reason. The type system encodes a business rule that used to live only in the heads of whoever wrote the original if status == "shipped" and not tracking_number: raise ValueError(...) check — and probably forgot to copy that check into the three other places that construct orders.

This is the point where type hints stop being about catching typos and start being about encoding the actual rules of your domain directly into the shapes your code is allowed to build. That’s architecture, not documentation.

4. Protocols: Designing Around Behavior, Not Inheritance

The next shift was in how I designed interfaces between components. I used to reach for abstract base classes almost automatically because that’s what “proper” object-oriented Python looked like. But ABCs require every implementer to explicitly opt in by inheriting from them which creates coupling I usually didn’t need.

typing.Protocol changed how I think about this entirely. It lets me define the shape of behavior a component needs, without forcing implementers to know that shape exists.

from typing import Protocol, Iterable

class MetricsSink(Protocol):
    def record(self, name: str, value: float, tags: dict[str, str]) -> None: ...
    def flush(self) -> None: ...

class StdoutMetrics:
    def record(self, name: str, value: float, tags: dict[str, str]) -> None:
        print(f"[metric] {name}={value} tags={tags}")
    def flush(self) -> None:
        print("[metrics flushed]")

class DatadogMetrics:
    def __init__(self, client) -> None:
        self._client = client
    def record(self, name: str, value: float, tags: dict[str, str]) -> None:
        formatted_tags = [f"{k}:{v}" for k, v in tags.items()]
        self._client.gauge(name, value, tags=formatted_tags)
    def flush(self) -> None:
        self._client.flush()

def report_batch_job(metrics: MetricsSink, jobs: Iterable[str]) -> None:
    for job in jobs:
        metrics.record("job.completed", 1.0, {"job": job})
    metrics.flush()

Neither StdoutMetrics nor DatadogMetrics imports or inherits from MetricsSink. They satisfy it structurally, the way duck typing always worked in Python — except now mypy or pyright actually verifies the duck really does quack correctly, at every call site, before the code ever runs.

The architectural payoff here is significant: Protocols let you define the seams of your system before you decide who lives on either side of them. I now write the Protocol for a component first, as a design exercise, the same way I'd sketch an interface in a design doc — except this interface is executable and enforced.

5. Generics: Types That Describe Relationships, Not Just Values

The biggest unlock for me was realizing generics aren’t just for library authors writing List[T]-style containers. They're how you express relationships between inputs and outputs that a plain type hint can't capture.

Here’s a repository pattern I use constantly, generic over the entity type:

from typing import Generic, TypeVar, Protocol, Optional
T = TypeVar("T")
ID = TypeVar("ID")

class Repository(Protocol, Generic[ID, T]):
    def get(self, entity_id: ID) -> Optional[T]: ...
    def save(self, entity: T) -> None: ...
    def delete(self, entity_id: ID) -> None: ...

@dataclass(frozen=True)
class User:
    id: str
    email: str

class InMemoryUserRepository:
    def __init__(self) -> None:
        self._store: dict[str, User] = {}
    def get(self, entity_id: str) -> Optional[User]:
        return self._store.get(entity_id)
    def save(self, entity: User) -> None:
        self._store[entity.id] = entity
    def delete(self, entity_id: str) -> None:
        self._store.pop(entity_id, None)

def deactivate_user(repo: Repository[str, User], user_id: str) -> None:
    user = repo.get(user_id)
    if user is None:
        raise ValueError(f"No user with id {user_id}")
    # deactivate logic here
    repo.save(user)

Repository[str, User] tells you, at a glance and enforced by the type checker, exactly what kind of ID this repository uses and exactly what entity it stores. Swap User for Order and every function that depends on Repository[str, Order] is now type-checked against the relationship between ID and entity, not just the individual pieces.

This is where types stop describing individual variables and start describing the shape of your system’s data flow — which is again architecture.

6. Runtime Boundaries: Where Static Types Aren’t Enough

Static type hints are a compile-time promise. They say nothing about what actually arrives over the network, gets read from a config file or comes back from a database query. I learned this the hard way when a static type checker happily approved code that crashed instantly in production because the “guaranteed” int from an API was actually a string half the time.

This is where pydantic earns its place in the architecture — not as a replacement for dataclasses but as the gatekeeper at every boundary where untrusted or external data enters the system.

from pydantic import BaseModel, EmailStr, field_validator
from typing import Optional
class IncomingUserPayload(BaseModel):
    user_id: str
    email: EmailStr
    name: Optional[str] = None
    signup_source: str
    @field_validator("signup_source")
    @classmethod
    def validate_source(cls, value: str) -> str:
        allowed = {"web", "mobile", "referral", "api"}
        if value not in allowed:
            raise ValueError(f"signup_source must be one of {allowed}")
        return value

def handle_signup_request(raw_body: dict) -> IncomingUserPayload:
    # raises a structured, catchable ValidationError if the shape is wrong
    payload = IncomingUserPayload.model_validate(raw_body)
    return payload

The architectural principle I now follow strictly: every external boundary gets a validating model, and once data passes that boundary, it moves through the system as a trusted, statically-typed object — a dataclass, a Protocol-satisfying class, whatever fits. Static types handle the internal contracts. Runtime validation handles the untrusted edges. Conflating the two is how you end up with a type checker that’s technically right and a production incident that’s very real.

7. Enforcing the Architecture: Strict Mode Is Not Optional

None of the above matters if type checking is advisory. For the first year I used type hints, mypy ran in default mode which is lenient enough to let Any leak in everywhere and quietly disable itself function by function. I was writing architecture and then not enforcing it.

The shift was flipping to strict mode project-wide and treating a type-check failure the same way I’d treat a failing test — a blocker, not a suggestion.

# mypy.ini
[mypy]
python_version = 3.12
strict = True
disallow_untyped_defs = True
disallow_any_generics = True
disallow_incomplete_defs = True
check_untyped_defs = True
no_implicit_optional = True
warn_redundant_casts = True
warn_unused_ignores = True
warn_return_any = True

And in CI, making it a hard gate rather than a lint warning that everyone learns to scroll past:

# .github/workflows/typecheck.yml
name: Type Check
on: [pull_request]
jobs:
  mypy:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-python@v5
        with:
          python-version: "3.12"
      - name: Install dependencies
        run: |
          pip install mypy pydantic
          pip install -r requirements.txt
      - name: Run mypy in strict mode
        run: mypy src/ --strict

Strict mode is uncomfortable at first. It surfaces every Any you were quietly relying on, every function missing a return type, every place Optional was implicit instead of declared. That discomfort is the whole point — it's the type checker finding the exact same gaps in your architecture that a code reviewer would eventually find, except it finds them in three seconds instead of during a production postmortem.

8. Refactoring With Confidence: The Payoff

Here’s where all of this compounds. Six months after making this shift, I needed to change the UserSummary model from earlier — adding a required locale field that every downstream consumer needed to account for.

Before this shift, that change meant grepping for every place a dict with those keys might be constructed, hoping the grep caught everything, and shipping with fingers crossed.

After this shift, the change looked like this:

@dataclass(frozen=True)
class UserSummary:
    id: str
    display_name: str
    contact: str
    locale: str  # newly added, required

Running mypy --strict against the codebase immediately produced a precise, exhaustive list — every constructor call missing the new argument, every function pattern-matching on the old shape, every test fixture that needed updating. Nothing was left to memory or grep-based hope. The type checker turned a change that used to be an anxious afternoon into a mechanical checklist I worked through in twenty minutes.

That’s the actual return on treating types as architecture: refactors stop being an act of faith and become an act of following a list the compiler generates for you.

9. What This Changed About How I Design, Not Just How I Code

Looking back, the biggest shift wasn’t syntactic — it was in when I write types relative to when I write logic. I now sketch the Protocols and dataclasses for a new feature before I write the functions that use them, the same way I used to sketch a database schema before writing queries against it. The types are the design. The functions are the implementation of that design.

A few habits fell out of this that I now apply on every project:

# Habit 1: no bare dict or Any at a function boundary without a very good reason
def process(data: dict) -> dict: ...          # red flag
def process(data: OrderPayload) -> OrderResult: ...  # this is the goal
# Habit 2: illegal states get their own type, not a flag on an existing one
is_cancelled: bool  # red flag - invites contradictory combinations
status: OrderStatus  # this is the goal
# Habit 3: Protocols before implementations, when designing a new component boundary
class Sink(Protocol):
    def write(self, record: dict) -> None: ...
# implementations come after the contract is agreed on, not before

None of this makes Python behave like a statically compiled language and I don’t want it to. What it does is turn the type system from a documentation aid into a design tool — one that catches the exact class of bug that used to reach production silently: the one where the code was never wrong on its own, only wrong in combination with something three files away that nobody was looking at when they made their change.

That’s the real difference between documentation and architecture. Documentation describes what you built. Architecture constrains what you’re allowed to build in the first place — and once I started using type hints that way, I stopped losing afternoons to bugs that a five-second type check would have caught before I ever ran the code.


메타데이터
post_id
6e0637ea63df
slug
i-started-treating-python-type-hints-as-architecture-not-documentation-6e0637ea63df
url
https://medium.com/@maximilianoliver25/i-started-treating-python-type-hints-as-architecture-not-documentation-6e0637ea63df
canonical_url
https://medium.com/@maximilianoliver25/i-started-treating-python-type-hints-as-architecture-not-documentation-6e0637ea63df
author_url
https://medium.com/@maximilianoliver25
status
ok
fetched_at
2026-08-06 20:16:35