Modern Python Typing: Static Analysis and Runtime Verification

Part of the Python in Production: Architecture, Robustness, Async & FFI series

Type hints in Python are metadata. The runtime does not enforce them, and a static checker cannot see the payloads that arrive at a running service. A function annotated user_id: int accepts any type without complaint, and the crash happens later, far from the call site, where the error details are already lost. Python is gradually typed, not statically typed or dynamically typed. The checker you run decides which it is: a codebase under pyright --strict is statically typed in practice; one without hints is dynamically typed. PEP 484’s goal was optional static typing layered on a dynamic runtime, and both layers are part of the design.

Two layers of defense keep a production service safe. Static type checkers catch logical errors in code you control, before it ships. Runtime type assertions catch the type violations that arrive from outside.


The cost of ignored types at runtime

Type hints are metadata the interpreter reads, then ignores during execution:

def process_id(user_id: int) -> str:
return f"ID: {user_id + 1}"
# a checker flags this; the runtime does not
process_id("not-an-int")

A static checker sees the str and rejects it. At runtime the call executes, and the TypeError fires inside the addition. The static layer finds the mistake before deploy; it cannot protect a service from a request body, a database record, or a Kafka message that arrives in production.

payload = {"user_id": "not-an-int"} # parsed JSON, no annotation to check

The static layer checked the code, not the data. Once an untrusted payload crosses into your process, no annotation stands between it and your business logic: the dict flows through functions with perfectly annotated signatures, and every signature is a promise the interpreter never verifies. A runtime checker re-reads those hints at call time, so the violation raises at the boundary with a message naming the parameter, instead of three frames deeper as a TypeError inside an unrelated operation.

CI Success CI Fails Execute Body Raise Violation Static Check Source Code Checker Passes? Yes COMPILE TIME No Enter Decorator Dynamic Call beartype check Valid Invalid RUNTIME O(1)

Static layer: mypy for the support, ty for the performance

Four production-ready checkers cover most use cases, each with a different trade-off. I usually start with ty; if the project needs a PEP rule that ty has not implemented yet, I fall back to mypy or pyright, which are slower but more complete.

ToolStrengthsWatch out for
mypyUsually the default; mature; most plugins; canonical PEP complianceSlowest of the four on large codebases
pyright (Microsoft)Pylance is built on it; the de facto VS Code defaultLess mature plugin ecosystem than mypy
ty (Astral)The project docs report 10-100x faster than mypy/pyright; written in Rust; same authors as ruff and uvNew; not all PEP features implemented
pyrefly (Meta)Rust-based; designed for large monorepos; Meta-internal lineageNew; smaller community than pyright

As an example, let’s see how to configure mypy to keep Any out of signatures:

[tool.mypy]
disallow_any_explicit = true
disallow_any_generics = true
disallow_untyped_defs = true
enable_error_code = ["deprecated", "redundant-cast", "unused-ignore"]
  • disallow_any_explicit = true rejects Any written directly in an annotation. This is the flag that actually blocks def f(x: Any) -> Any; the two below do not.
  • disallow_any_generics = true rejects bare generics like list without a type argument, forcing list[str].
  • disallow_untyped_defs = true requires every function and method to carry annotations, so untyped code cannot slip in.
  • enable_error_code = ["deprecated", "redundant-cast", "unused-ignore"] turns on extra error codes. unused-ignore makes a # type: ignore that suppresses nothing its own error, so obsolete suppressions fail CI.

Each flag closes a specific leak path. disallow_any_explicit stops a helper that accepts anything from passing the problem downstream.

disallow_any_generics catches items: list, where the checker forgets what the items are and items[0] degrades to Any anyway. disallow_untyped_defs catches the untyped wrapper around a third-party library, a hole the checker cannot see through and the most common way an unvetted type enters a codebase. The last flag, unused-ignore, is hygiene: obsolete suppressions fail CI instead of silently hiding a new error behind an old # type: ignore.


Runtime layer: checking types at call time with beartype

beartype is a runtime type checker: it enforces a function’s annotated signature at call time, without a defensive try/except on every parameter. It generates the check closure at decoration time (once, at module import), so the per-call cost is constant time O(1), independent of hint complexity.

It is not the only option, and the alternatives differ on where and how they check. typeguard wraps functions with @typechecked and recursively validates every argument on each call, which is thorough but costs time proportional to the hint’s complexity.

pydantic and msgspec are not call-time checkers at all; they validate at construction, which makes them boundary validators (the decision tree below covers that split). enforce and pytypes fill the same niche as typeguard with smaller communities. beartype’s constant-time check is usually the reason it’s recommended as the default option.

Let’s see it in action through both layers: a Repository Protocol defines the port, TypeGuard narrows a User into an AdminUser, and @beartype enforces the service’s signature at call time. The static layer verifies that the adapter conforms to the port and that the narrowing is sound; the runtime layer catches the objects that arrive with no checker watching.

from typing import Protocol
class Repository[T](Protocol):
async def save(self, entity: T) -> None: ...
async def get_by_id(self, entity_id: str) -> T | None: ...

Protocol is structural typing: any class with save and get_by_id methods conforms, without declaring an inheritance. That is the point over abc.ABC: the adapter never imports the port.

from dataclasses import dataclass
@dataclass
class User:
id: str
role: str
@dataclass
class AdminUser(User):
admin_access_key: str

A TypeGuard narrows User to AdminUser inside an if:

from typing import TypeGuard
def is_admin_user(user: User) -> TypeGuard[AdminUser]:
return isinstance(user, AdminUser)
def execute_admin_task(user: User) -> None:
if is_admin_user(user):
print(f"Executing admin task with key: {user.admin_access_key}")

Inside the if is_admin_user(user): block, the checker narrows user to AdminUser. This replaces the unsafe cast(AdminUser, user) form: TypeGuard narrows on the return value of a function, and the checker verifies the narrowing is used correctly.

@beartype wraps the service that consumes the port:

from beartype import beartype
from beartype.roar import BeartypeCallHintParamViolation
@beartype
class UserService:
def __init__(self, repo: Repository[User]) -> None:
self.repo = repo
async def register_user(self, user: User) -> None:
await self.repo.save(user)
async def test_runtime_type_check() -> None:
invalid_user = {"id": "123", "role": "member"} # dict, not User
service = UserService(repo=InMemoryUserRepository())
try:
await service.register_user(invalid_user)
except BeartypeCallHintParamViolation as err:
print(f"Beartype blocked invalid input: {err}")

The violation fires at the parameter boundary, before the service runs. InMemoryUserRepository is a concrete adapter with save and get_by_id methods; its structural shape is what satisfies the Repository[User] port.

Note the interaction with generic erasure: Repository[User] is Repository at runtime, so beartype checks the structural shape (does the object have save and get_by_id?) but not the parametric type of the contents. The static checker verifies the parametric type User at compile time only; at runtime the argument is erased and unrecoverable. The static layer owns parametric types; the runtime layer owns structure.

@runtime_checkable and generics

typing.Protocol is static by default; isinstance(obj, Repository) raises TypeError. @runtime_checkable enables the check. The Repository from the runtime section, now decorated:

from typing import Protocol, runtime_checkable
@runtime_checkable
class Repository[T](Protocol):
async def save(self, entity: T) -> None: ...
async def get_by_id(self, entity_id: str) -> T | None: ...

The generic subscripting limitation is not a corner case: isinstance(adapter, Repository[User]) fails because the runtime cannot resolve type parameters. The workaround is a concrete, non-subscripted subclass:

class UserRepositoryPort(Repository[User]): ...

Use this in dependency injection containers and startup validation loops, where you want to verify an injected adapter conforms to its declared port.

Enforce types package-wide with beartype_this_package()

Instead of manually annotating the classes and functions of a file, you can just use beartype’s import hook instead: one call in the package __init__ enforces every annotated callable in the package:

# in your_package/__init__.py
from beartype.claw import beartype_this_package
beartype_this_package()

This removes per-function boilerplate and makes the check opt-out instead of opt-in. The @beartype decorator stays useful for a specific hot function or for enforcing hints in a module outside the package.

Opting out is explicit. Beartype skips unannotated callables, so leaving a function without hints is one way out. For a hinted callable you want excluded (a hot loop, or a function where the hint is intentionally loose), decorate it with a no-op configuration: nobeartype = beartype(conf=BeartypeConf(strategy=BeartypeStrategy.O0)) turns the decorator into an identity function for that callable, overriding the import hook. For a global kill switch, running Python with -O or PYTHONOPTIMIZE disables all beartype checks.

Mocking a Protocol: why plain AsyncMock() fails and spec= fixes it

A plain unittest.mock.AsyncMock() fails the structural check. hasattr(plain_mock, "save") is False because mock methods materialize only when called or when spec= populates them. The fix is AsyncMock(spec=Repository), which mirrors the protocol’s methods:

from unittest.mock import AsyncMock
async def test_beartype_mock_workaround() -> None:
spec_mock = AsyncMock(spec=Repository)
service = UserService(repo=spec_mock)
await service.register_user(User(id="2", role="admin"))

The spec-mirroring mechanism is the same one that makes @runtime_checkable Protocol checks shallow: they verify method presence, not signature. An adapter whose save lacks the entity parameter still passes isinstance, then fails at call time.


Tying it all together with Annotated

The two layers meet in typing.Annotated. Each consumer pulls the metadata it understands out of the same annotation: pydantic and FastAPI read their own markers, beartype reads its validators, and the static checker reads the type. One annotation to serve them all!

from beartype import beartype
from beartype.vale import Is
from typing import Annotated
NonEmptyList = Annotated[list[str], Is[lambda lst: bool(lst)]]
@beartype
def consume(items: NonEmptyList) -> None: ...

The annotation carries the type and the constraint together. But order matters: per PEP 593, static checkers read only the first argument of Annotated and must ignore metadata they don’t recognize. So Annotated[int, Is[...]] narrows statically to int, and the Is[...] validator runs only when something at runtime reads it.

That split also tells you when this is worth it. Annotated constraints make sense for invariants a static type cannot express (non-empty collections, value ranges, ordering) on data that flows through trusted call paths. For untrusted payloads at a network boundary, a construction-time validator (pydantic, msgspec) fits better: it validates once and deeply at the edge.


Takeaways

  • Run a strict static checker in CI.
  • Enforce runtime hints with beartype_this_package(), not per-function decorators.
  • Use beartype for function-contract checks on in-process data.
  • Use TypeGuard for runtime narrowing instead of cast, and Annotated to carry constraints that both the static and runtime checkers read.

Two important reminders:

  • beartype just checks that the types conform at the call boundary; you still need proper tests.
  • Runtime checks are not free. The O(1) claim is a per-call lower bound; a complex annotation like dict[str, list[tuple[int, str | None]]] has a larger constant factor. In tight loops where performance is critical, validate once at the boundary, then operate on data you already trust.

References and additional resources