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 notprocess_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 checkThe 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.
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.
| Tool | Strengths | Watch out for |
|---|---|---|
mypy | Usually the default; mature; most plugins; canonical PEP compliance | Slowest of the four on large codebases |
pyright (Microsoft) | Pylance is built on it; the de facto VS Code default | Less 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 uv | New; not all PEP features implemented |
pyrefly (Meta) | Rust-based; designed for large monorepos; Meta-internal lineage | New; 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 = truedisallow_any_generics = truedisallow_untyped_defs = trueenable_error_code = ["deprecated", "redundant-cast", "unused-ignore"]disallow_any_explicit = truerejectsAnywritten directly in an annotation. This is the flag that actually blocksdef f(x: Any) -> Any; the two below do not.disallow_any_generics = truerejects bare generics likelistwithout a type argument, forcinglist[str].disallow_untyped_defs = truerequires 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-ignoremakes a# type: ignorethat 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
@dataclassclass User: id: str role: str
@dataclassclass AdminUser(User): admin_access_key: strA 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 beartypefrom beartype.roar import BeartypeCallHintParamViolation
@beartypeclass 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_checkableclass 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__.pyfrom 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 beartypefrom beartype.vale import Isfrom typing import Annotated
NonEmptyList = Annotated[list[str], Is[lambda lst: bool(lst)]]
@beartypedef 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
beartypefor function-contract checks on in-process data. - Use
TypeGuardfor runtime narrowing instead ofcast, andAnnotatedto carry constraints that both the static and runtime checkers read.
Two important reminders:
beartypejust 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
- Python
typingmodule documentation - PEP 695 – Type Parameter Syntax
- PEP 647 – User-Defined Type Guards
- PEP 544 – Protocols: Structural subtyping
- PEP 593 - Flexible function and variable annotations
- pyright Documentation
- ty (Astral), type checker
- pyrefly (Meta), type checker
- beartype Documentation
- Pydantic
- msgspec