Tech Verse Logo
Enable dark mode
Python Type Hints and Mypy: Real World Patterns

Python Type Hints and Mypy: Real World Patterns

Md. Mostafijur RahmanMMd. Mostafijur Rahman

Md. Mostafijur Rahman

•4 min read

Adding type annotations to a 100,000-line Python codebase isn't something you do in a single sprint. If you try running mypy --strict . on an unannotated legacy service, you'll get thousands of errors and an engineering team ready to revert your commit. Python type hints mypy adoption works best when treated as a targeted safety net, not an all-or-nothing dogmatic pursuit.

The highest return on investment comes from annotating boundaries: API payloads, database access layers, and shared utility modules. Internal helper functions that change three times a week don't need total coverage on day one. Start by enforcing typing on public interfaces.

Gradual Typing: Start Where Bugs Actually Live

Here is a common scenario: parsing unstructured JSON payloads from external HTTP calls or webhooks sent from a Laravel 12 backend or microservice. Without standard annotations, raw dictionaries propagate through your Python domain layer until a missing key explodes in production with a KeyError at 3 AM.

from typing import TypedDict, NotRequired
import requests

class UserPayload(TypedDict):
    id: int
    email: str
    is_active: bool
    nickname: NotRequired[str]

def process_user_webhook(raw_data: dict[str, object]) -> UserPayload:
    if "id" not in raw_data or not isinstance(raw_data["id"], int):
        raise ValueError("Invalid or missing 'id' field")
    if "email" not in raw_data or not isinstance(raw_data["email"], str):
        raise ValueError("Invalid or missing 'email' field")
    
    return {
        "id": raw_data["id"],
        "email": raw_data["email"],
        "is_active": bool(raw_data.get("is_active", True)),
    }

Using TypedDict gives you editor autocompletion and static checks without forcing the runtime performance hit of heavier data-validation libraries when you only need light structural checks. Notice the parameter type dict[str, object] instead of dict[str, Any]. Using Any disables type checking entirely for downstream operations, allowing bad types to leak quietly past mypy.

Protocols vs Abstract Base Classes

Python's typing.Protocol introduced structural subtyping (duck typing) to mypy in Python 3.8, and under Python 3.12 and 3.13, it remains one of the most practical features in the standard library. Traditional object-oriented Python heavily relied on abc.ABC and explicit inheritance. That pattern creates rigid coupling between internal modules and external libraries.

When you build integrations—say, sending log events to third-party services or dispatching notifications—you shouldn't force third-party or wrapper classes to inherit from your custom base class. Define a protocol instead.

from typing import Protocol, runtime_checkable

@runtime_checkable
class EventLogger(Protocol):
    def log_event(self, name: str, payload: dict[str, object]) -> bool:
        ...

class DatadogLogger:
    def log_event(self, name: str, payload: dict[str, object]) -> bool:
        # Custom logic without inheriting from EventLogger
        print(f"Sending {name} to Datadog")
        return True

def track_purchase(logger: EventLogger, order_id: str) -> None:
    success = logger.log_event("order_placed", {"order_id": order_id})
    if not success:
        raise RuntimeWarning("Failed to dispatch log event")

Because DatadogLogger matches the signature defined in EventLogger, mypy satisfies the contract without explicit inheritance. If you change log_event in DatadogLogger to return an integer or take different argument names, mypy flags the error instantly with error: Argument 1 to "track_purchase" has incompatible type "DatadogLogger"; expected "EventLogger".

Mypy Configuration That Works in Production

A loose mypy.ini or pyproject.toml configuration is almost as useless as having no static analysis at all. Conversely, turning on strict mode overnight will stall development. The sweet spot lies in configuring settings that reject dangerous patterns while allowing incremental adoption.

Here is a production-proven configuration block for mypy 1.10+ running under Python 3.12:

[tool.mypy]
python_version = "3.12"
warn_return_any = true
warn_unused_configs = true
disallow_untyped_defs = true
disallow_incomplete_defs = true
check_untyped_defs = true
disallow_untyped_decorators = true
no_implicit_optional = true
warn_redundant_casts = true
warn_unused_ignores = true
show_error_codes = true

[[tool.mypy.overrides]]
module = "legacy_billing.*"
disallow_untyped_defs = false
check_untyped_defs = false

The no_implicit_optional flag catches a major trap that bit developers for years: writing def find_user(id: int = None). Older Python versions treated that as implicitly accepting Optional[int]. Modern Python and mypy require explicit union types like int | None. Making this flag explicit prevents subtle runtime AttributeError: 'NoneType' object has no attribute exceptions when callers pass None assuming the function handles it.

Notice the module-level overrides block. Using per-module overrides lets you enforce strict typing on fresh feature code inside app.services.* while keeping baseline checks relaxed for dusty legacy modules you haven't refactored yet.

Performance and CI Pipelines

Running mypy across a large codebase on every pull request can turn into a build pipeline bottleneck if configured incorrectly. In cold runs on full repositories, mypy parses ASTs and checks types for thousands of files, taking 30 to 45 seconds. You can cut this execution time dramatically.

First, enable cache persistence in your CI runner. Persisting the .mypy_cache directory across GitHub Actions or GitLab CI runs reduces total execution time from 35 seconds down to under 3 seconds for incremental commits.

Second, use dmypy (the mypy daemon) in local development environments. The mypy daemon runs as a background process and keeps module dependency graphs in memory. Running dmypy run -- path/to/file.py drops check latency down from 2.5 seconds to under 150 milliseconds. That speed difference makes type checking feel instant in your text editor.

Common Type Hint Gotchas to Avoid

One frequent mistake involves typing container parameters like lists and dicts as mutable structures when read-only access is intended. Passing a list[str] into a function that only reads elements prevents callers from passing a tuple[str, ...] or a generic Sequence[str]. Always prefer Sequence[T] or Mapping[K, V] for function arguments to maximize flexibility without breaking static promises.

Another issue is over-using # type: ignore comments without error codes. Writing # type: ignore suppresses every error on that line, including unrelated type regressions introduced six months later. Always use explicit error code annotations: # type: ignore[arg-type] or # type: ignore[union-attr]. Combined with warn_unused_ignores = true in your mypy settings, mypy will alert you as soon as a type fix renders the ignore comment obsolete, keeping your codebase clean.

Md. Mostafijur RahmanMMd. Mostafijur Rahman

WRITTEN BY

Md. Mostafijur Rahman

    Latest Posts

    View All

    Profiling Python: Finding the Actual Bottleneck

    Profiling Python: Finding the Actual Bottleneck

    SQLAlchemy 2.0 for Eloquent Developers

    SQLAlchemy 2.0 for Eloquent Developers

    Django vs FastAPI vs Flask: Pick the Right Python Stack

    Django vs FastAPI vs Flask: Pick the Right Python Stack

    Clean Pytest: Fixtures, Parametrisation, and Mocks

    Clean Pytest: Fixtures, Parametrisation, and Mocks

    Async Python: asyncio Without the Confusion

    Async Python: asyncio Without the Confusion

    Python Type Hints and Mypy: Real World Patterns

    Python Type Hints and Mypy: Real World Patterns

    FastAPI for PHP Developers: Core Concepts Mapped

    FastAPI for PHP Developers: Core Concepts Mapped

    Python venv vs uv vs Poetry: Choosing for Production

    Python venv vs uv vs Poetry: Choosing for Production

    Testing LLM Integration Without Flaky CI Runs

    Testing LLM Integration Without Flaky CI Runs

    Integrating Image Generation APIs: Prompts, Ratios, Storage

    Integrating Image Generation APIs: Prompts, Ratios, Storage