Dev.to Security 🔐 Cybersecurity 👁 0 📖 5 min read

Python type hints for production: mypy, pyright and real-world patterns

Python's dynamic typing is a double-edged sword. At the REPL or in a 500-line script, it's liberating. In a production codebase with six contributors and two years of history, it's a liability. Type hints change that — b

Python's dynamic typing is a double-edged sword. At the REPL or in a 500-line script, it's liberating. In a production codebase with six contributors and two years of history, it's a liability. Type hints change that — but only if you use them right and pair them with a strict checker.

Why bother? The real cost of untyped Python

The usual argument for type hints is "IDE autocompletion" — which undersells them completely. The actual value is catching bugs before they reach production.

Consider this function:

def get_user_role(user_id):
    user = db.get(user_id)
    return user["role"]

It works fine in tests where db.get always returns a dict. It blows up in production when db.get returns None for a missing user. With types:

from typing import Optional

def get_user_role(user_id: int) -> Optional[str]:
    user = db.get(user_id)
    if user is None:
        return None
    return user["role"]

Now a type checker forces you to handle the None case. That's not just documentation — it's a constraint that prevents a class of bugs.

In security-sensitive code paths (authentication, authorization, input validation), this matters even more. A missed None check in a permissions function isn't just a crash — it can be an exploitable condition. This is the kind of issue that shows up in security hardening checklists for Python applications.

Picking your checker: mypy vs pyright

Both are good. The choice depends on your workflow.

mypy is the original. It's slower on large codebases but has the most mature ecosystem. Plugins exist for Django, SQLAlchemy, Pydantic, and others.

pyright (also the engine behind Pylance in VS Code) is significantly faster and has stricter inference by default. If you're greenfielding a project, pyright in strict mode is the better choice.

In practice: use pyright for real-time feedback in the editor, and mypy in CI where the extra plugin support pays off.

A minimal pyproject.toml configuration for both:

[tool.mypy]
python_version = "3.12"
strict = true
ignore_missing_imports = false

[[tool.mypy.overrides]]
module = "third_party_lib.*"
ignore_missing_imports = true

[tool.pyright]
pythonVersion = "3.12"
typeCheckingMode = "strict"
reportMissingImports = true

Start with strict = true on new code. For legacy codebases, add # type: ignore pragmatically and track the count — it should decrease over time, never increase.

Real-world patterns that actually come up

TypedDict for structured data

When you're passing around dicts with known shapes — config objects, API responses, database rows before ORM — TypedDict is cleaner than a full dataclass:

from typing import TypedDict

class AuditEvent(TypedDict):
    user_id: int
    action: str
    resource: str
    timestamp: float
    success: bool

def log_event(event: AuditEvent) -> None:
    print(f"[{event['timestamp']}] user={event['user_id']} action={event['action']}")

This is especially useful for audit trail data or any place where the schema matters but you don't want the overhead of a full ORM model.

Protocol for structural typing

Protocols let you define interfaces without inheritance — Python's answer to Go's implicit interfaces:

from typing import Protocol

class Authenticatable(Protocol):
    def verify_token(self, token: str) -> bool: ...
    def get_permissions(self) -> list[str]: ...

def authorize(user: Authenticatable, required_permission: str) -> bool:
    if not user.verify_token(token="..."):
        return False
    return required_permission in user.get_permissions()

Any class with verify_token and get_permissions methods satisfies Authenticatable — no inheritance required. This is more flexible than ABCs and plays better with third-party code you don't control.

Annotated for validation metadata

Python 3.9+ Annotated lets you attach metadata to types. Pydantic v2 uses this extensively:

from typing import Annotated
from pydantic import BaseModel, Field

UserId = Annotated[int, Field(gt=0, description="Positive integer user ID")]
Email  = Annotated[str, Field(pattern=r"^[^@]+@[^@]+\.[^@]+$")]

class CreateUserRequest(BaseModel):
    user_id: UserId
    email: Email
    role: str

This keeps validation close to the type definition rather than scattered across separate validators. The type alias (UserId, Email) also shows up in error messages, which makes debugging faster.

Common pitfalls and how to fix them

Any as a crutch. Every Any is a type checker escape hatch. It's sometimes necessary — dynamic dispatch, legacy code, badly typed third-party libraries — but each one is a blind spot. Enable --warn-return-any in mypy and audit Any usages in code review.

Mutable default arguments typed incorrectly. The classic Python footgun:

# Wrong — both the type annotation and the logic
def add_item(item: str, collection: list[str] = []) -> list[str]:
    collection.append(item)
    return collection

# Correct
def add_item(item: str, collection: list[str] | None = None) -> list[str]:
    if collection is None:
        collection = []
    collection.append(item)
    return collection

mypy won't flag the shared mutable default directly, but requiring None as the default makes the safe pattern natural.

Overly broad ignore_missing_imports. If a library ships without type stubs, mypy treats everything it exports as Any. Use [[tool.mypy.overrides]] to suppress warnings for specific packages rather than setting ignore_missing_imports = true globally — the global flag masks real errors.

Not using reveal_type during debugging. When you're unsure what type mypy infers, insert reveal_type(variable) anywhere and run mypy — it prints the inferred type without failing. Remove it before committing.

The takeaway

Type hints are not just documentation. Used with mypy or pyright in strict mode, they form a lightweight static analysis layer that catches a real class of bugs before code ships. The overhead is front-loaded — setting up the config, annotating existing code — but the ongoing cost is low and the signal is high.

The return on investment is highest for:

  • Functions that handle user input or authentication
  • Any function that returns Optional or raises conditionally
  • Code paths that call external APIs or touch databases

If you're starting today: enable pyright strict in your editor, add mypy to CI with strict = true, and treat every # type: ignore comment as technical debt. Count them in your CI output and trend them down over time.

I run AYI NEDJIMI Consultants, a cybersecurity consulting firm. We publish free security hardening checklists — PDF and Excel.

📰 Read the original article on Dev.to Security

Originally published by Dev.to Security. Aggregated on AIWithGhost for educational purposes — full credit and traffic to the original publisher.