Python Typing in Production: Beyond mypy Hello-World
Python·By Islam Gamal··25 min read

Python Typing in Production: Beyond mypy Hello-World

TypedDict, Protocol, Literal, generics, and how to actually enforce types on a large Python codebase without slowing your team down.

Islam Gamal
Islam Gamal
AI · Data · Automation Engineer
TL;DR

Type hints in Python are worth the effort — but only with a strict configuration you actually enforce in CI. TypedDict, Protocol, and Literal cover 90% of real cases; generics cover the rest. Pyright is the checker to use in 2026.

Key takeaways
  • Untyped Python at scale becomes a codebase you can't refactor. Types are the escape hatch.
  • Pyright is faster and stricter than mypy; run it in CI, not just in editors.
  • TypedDict maps JSON-shaped data to types without dataclass overhead.
  • Protocol is duck typing you can typecheck — the right tool for injected dependencies.
  • Literal + Union gives you exhaustive checks for enum-like values without an Enum class.

Why bother

Type hints do three things that matter more the bigger your codebase gets:

  • They document the intended shape of every function without a doc block.
  • They let a checker prove that a refactor didn't break anything, in milliseconds.
  • They give editors real autocomplete instead of guessing.

On a 50k-line codebase I inherited untyped, retrofitting types took two engineers two months and paid back within the same quarter in caught refactor bugs. If you have >10k lines of Python and no plan for types, you have a growing tax you don't yet see.

Pyright, not mypy, in CI

mypy is the historical default and it's fine. Pyright (from Microsoft, also powers Pylance) is faster, stricter by default, and evolves faster with new PEPs. In 2026 pyright is the pragmatic pick.

# pyproject.toml
[tool.pyright]
typeCheckingMode = "strict"
reportMissingTypeStubs = false     # third-party packages
reportUnknownMemberType = false    # too noisy on untyped libs
pythonVersion = "3.12"
venvPath = "."
venv = ".venv"

Run pyright in CI. Fail the build on error. Do not accept warnings-only mode after the first month — it decays.

TypedDict for JSON-shaped data

You receive JSON from an API or database. Don't parse it into a dataclass just for typing — TypedDict gives you type safety with zero runtime cost:

from typing import TypedDict, NotRequired

class OrderRow(TypedDict):
    id: str
    customer_id: str
    total_cents: int
    currency: Literal['USD', 'EUR', 'GBP']
    metadata: NotRequired[dict[str, str]]

def get_order(row: OrderRow) -> int:
    return row['total_cents']  # checker knows this is int

Protocol for structural typing

Duck typing is Python's soul, but classic duck typing can't be typechecked. Protocol brings it back with static verification:

from typing import Protocol

class UserRepo(Protocol):
    def get(self, user_id: str) -> User | None: ...
    def save(self, user: User) -> None: ...

def register(repo: UserRepo, email: str) -> User:
    ...

Any class with matching methods satisfies UserRepo — no inheritance needed. This is the right way to type injected dependencies and makes tests trivial to fake.

Literal + Union for enum-like fields

Instead of an Enum class for a simple discriminator, use Literal:

Status = Literal['pending', 'active', 'closed']

def transition(current: Status, event: str) -> Status:
    match (current, event):
        case ('pending', 'activate'): return 'active'
        case ('active', 'close'): return 'closed'
        case _: raise ValueError

Pyright will error if you pass a string that isn't one of those three, and will exhaustiveness-check match if you annotate the return with the same Literal union. Enum works too, but Literal is lighter and serializes to JSON as-is.

Generics that pay off

Most day-to-day generics come from list[T] and dict[K, V]. Write your own generic only when you have a container or wrapper that operates over many types:

from typing import TypeVar, Generic

T = TypeVar('T')

class Paginated(Generic[T]):
    def __init__(self, items: list[T], next_cursor: str | None):
        self.items = items
        self.next_cursor = next_cursor

def list_orders() -> Paginated[Order]: ...

Rolling it out on an existing codebase

  1. Turn on pyright in warn-only mode. Capture the current error count.
  2. Fix the lowest-hanging fruit (missing return types on new code, obvious Any escapes).
  3. Enable strict mode module-by-module using [[tool.pyright.strict]] patterns.
  4. Add pyright to CI as a warning, then a failure, on a fixed date.
  5. Add a lint rule that forbids new Any outside a small allowlist.

This roadmap has moved multiple large codebases from 'no types' to 'strict everywhere' inside a quarter without a rewrite.

#Python#Typing#mypy#pyright#Backend

Frequently asked

Types slow me down. Are they worth it?

In prototypes, skip them. In anything that outlives a quarter and has more than one contributor, they save more time than they cost within weeks.

Do I need Pydantic if I have types?

Different jobs. Types describe intent to the checker. Pydantic validates runtime data. Use both: types everywhere, Pydantic at trust boundaries (API inputs, config loading).

What about runtime type checking?

Overkill for most code. Use Pydantic at the edges and trust types internally. beartype is useful in libraries.

Building something similar?

I help teams ship production-grade AI agents, n8n workflows, and data platforms. Let's talk about what you're building.

Work with me
Islam Gamal
Written by

Islam Gamal

AI, Data & Automation Engineer. I design and ship production AI agents, n8n workflows, and cloud data platforms — with a focus on reliability, cost, and measurable business impact. Founder of Tashghil and Tek bil Arabi.

More from Islam Gamal

Browse every article by Islam Gamal on the author page.