Pedram Agand
← Writing
Programming

Python Type Hints That Actually Catch Bugs in Production

Most Python type hint guides show you the syntax. This one shows you the patterns that catch real bugs before they reach users.

2026-02-20·4 min read·Python, type hints, mypy, production
Use with AI
Python Type Hints That Actually Catch Bugs in Production

Python type hints are widely adopted but poorly used. Most codebases use them as documentation — decorative annotations that tell developers what type a variable is. That's useful, but you're leaving most of the value on the table.

The real payoff is catching logic bugs at the type level, before runtime. Here's what actually works.

The NewType Pattern for Domain Invariants

The most underused typing primitive in Python. Consider:

from typing import NewType

UserId = NewType("UserId", int)
AccountId = NewType("AccountId", int)

def get_user(user_id: UserId) -> User: ...
def get_account(account_id: AccountId) -> Account: ...

# This is a bug — passing account ID where user ID is expected
# Without NewType, mypy won't catch it. With NewType, it will.
user = get_user(account_id)  # mypy error: Argument 1 has incompatible type "AccountId"; expected "UserId"

Both UserId and AccountId are int at runtime. But mypy treats them as distinct types. A function expecting a UserId won't accept an AccountId — which is exactly what you want, because passing the wrong ID is a real bug that happens in production.

Use NewType for any domain value where identity matters: IDs, currencies, timestamps in different timezones.

Literal Types for State Machines

When you have a field with a fixed set of values, use Literal instead of str:

from typing import Literal

OrderStatus = Literal["pending", "processing", "completed", "cancelled"]

def process_order(order_id: str, status: OrderStatus) -> None:
    match status:
        case "pending":
            ...
        case "processing":
            ...
        # If you add a new status to OrderStatus and forget to handle it,
        # mypy will warn you about an unhandled case

def update_status(order_id: str, new_status: str) -> None:
    # This won't typecheck — str is too broad
    process_order(order_id, new_status)  # error

The key insight: Literal types make exhaustiveness checking possible. Add a new value to your Literal union and mypy will tell you every function that needs to be updated.

TypedDict for Configuration and API Responses

Stop using dict[str, Any] for structured data:

from typing import TypedDict, Required, NotRequired

class ModelConfig(TypedDict):
    model: str
    temperature: float
    max_tokens: int
    system_prompt: NotRequired[str]  # optional field

# mypy knows the shape of this dict
def run_inference(config: ModelConfig) -> str:
    return call_api(
        model=config["model"],          # ok
        temp=config["tempurature"],     # mypy error: TypedDict "ModelConfig" has no key "tempurature"
    )

This catches the typo at type-check time, not at 3am when an API call fails in production. TypedDict also handles optional fields via NotRequired, which dataclass doesn't do as elegantly.

Protocol for Structural Typing

Avoid importing concrete types from modules you don't own:

from typing import Protocol

class Vectorstore(Protocol):
    def similarity_search(
        self,
        embedding: list[float],
        k: int,
    ) -> list[str]: ...

# Works with any class that has this method — Pinecone, Chroma, Weaviate, or your mock
def retrieve(store: Vectorstore, query_embedding: list[float]) -> list[str]:
    return store.similarity_search(query_embedding, k=5)

Protocol is Python's structural subtyping. If a class has the right methods, it satisfies the protocol — no explicit inheritance required. This makes testing trivial: your mock just needs the right method signature.

The Runtime Guard Pattern

Type hints don't enforce anything at runtime. For values crossing system boundaries (API inputs, file reads, deserialized data), you need runtime validation. The pattern I use:

from typing import TypeGuard

def is_valid_order_status(value: str) -> TypeGuard[OrderStatus]:
    return value in ("pending", "processing", "completed", "cancelled")

def handle_webhook(payload: dict) -> None:
    status = payload.get("status", "")

    if not is_valid_order_status(status):
        raise ValueError(f"Invalid status: {status}")

    # After the guard, mypy knows status is OrderStatus
    process_order(payload["order_id"], status)  # ok

TypeGuard tells mypy that your runtime check narrows the type. After calling is_valid_order_status(x) and getting True, mypy treats x as OrderStatus in that branch.

Making It Stick

These patterns only work if mypy actually runs. The minimal mypy config that catches real bugs:

# mypy.ini
[mypy]
python_version = 3.12
strict = true
warn_return_any = true
warn_unused_ignores = true

strict = true enables the full check suite. warn_return_any = true catches functions that silently return Any, which is where type information leaks out of your system. warn_unused_ignores = true prevents # type: ignore comments from accumulating without review.

Run mypy in CI. If it's not blocking merges, it's not catching bugs.

Want this implemented in your workflow?

I work with SaaS companies, real-estate, finance, and regulated-industry teams on AI adoption. Book a 20-minute strategy call — no pitch, just a focused conversation about your situation.

I publish one post like this per month. Join AI Command Room and I'll send it directly to you.