Tipar una codebase existente de golpe es una receta para una semana de dolor y un PR que nadie quiere revisar. Python fue diseñado para que el type checking gradual sea la norma, no la excepción: el sistema de tipos es completamente opcional en tiempo de ejecución y el checker solo interviene cuando tú se lo pides.
El mecanismo que hace posible esto es la separación entre anotaciones y ejecución. Un def process(data: list[str]) -> int se comporta exactamente igual en tiempo de ejecución que def process(data). Las anotaciones son metadatos almacenados en __annotations__, ignorados por el intérprete. Mypy o Pyright los leen por separado, sin tocar tu programa.
¿Cuándo empezar? Siempre que tengas un bug causado por un tipo inesperado, o cuando un módulo está a punto de ser consumido por más código. No antes. El error clásico es tipar internals primero y las interfaces públicas al final: es exactamente al revés. Las interfaces públicas (funciones exportadas, métodos de clases que otros módulos instancian, APIs HTTP handlers) son el primer objetivo porque producen el mayor retorno: un error de tipos en la frontera de un módulo se propaga a todos sus consumidores.
Si tipas en el orden equivocado, mypy empieza a quejarse de dependencias que aún no tienen anotaciones, lo que genera ruido que entrena a tu equipo a ignorar el output del checker. Ese es el peor resultado posible.
# users/models.py
from __future__ import annotations # permite forward references sin evaluar en runtime
from dataclasses import dataclass, field
from datetime import datetime
from typing import TYPE_CHECKING
if TYPE_CHECKING:
# Este import solo existe para mypy; nunca se ejecuta en producción.
# Rompe ciclos de importación sin coste en runtime.
from billing.models import Invoice
@dataclass
class User:
id: int
email: str
created_at: datetime
tags: list[str] = field(default_factory=list)
def get_display_name(self) -> str:
return self.email.split("@")[0]
def pending_invoices(self) -> list[Invoice]:
# Importación real dentro de la función para evitar el ciclo en runtime
from billing.models import Invoice # noqa: PLC0415
return Invoice.for_user(self.id)
# users/service.py
from __future__ import annotations
from typing import Any
from .models import User
def find_user(user_id: int) -> User | None:
"""Interface pública: anotada completamente."""
raw = _fetch_from_db(user_id)
if raw is None:
return None
return _deserialize(raw)
def bulk_update_tags(user_ids: list[int], tags: list[str]) -> int:
"""Retorna el número de usuarios actualizados."""
updated = 0
for uid in user_ids:
user = find_user(uid)
if user is not None:
_save_tags(uid, tags)
updated += 1
return updated
def _fetch_from_db(user_id: int) -> dict[str, Any] | None: # type: ignore[return]
# TODO: este módulo legacy usa un ORM sin stubs.
# type: ignore documentado: remove when orm-legacy gets stubs (issue #142)
...
def _deserialize(raw: dict[str, Any]) -> User:
return User(
id=raw["id"],
email=raw["email"],
created_at=raw["created_at"],
tags=raw.get("tags", []),
)
def _save_tags(user_id: int, tags: list[str]) -> None:
...
# mypy.ini o pyproject.toml — configuración para avanzar gradualmente [tool.mypy] python_version = "3.12" ignore_missing_imports = true # no bloquear en librerías sin stubs warn_return_any = true # Any que escapa a través de returns es señal de deuda warn_unused_ignores = true # te avisa cuando un type: ignore ya no es necesario # Módulos ya completamente tipados: activar reglas estrictas [[tool.mypy.overrides]] module = ["users.*"] disallow_untyped_defs = true disallow_any_generics = true # Módulos legacy: modo permisivo por ahora [[tool.mypy.overrides]] module = ["legacy.*", "third_party_no_stubs.*"] ignore_errors = true
# Estructura del paquete cuando está listo para publicar tipos
users/
__init__.py
py.typed ← archivo vacío; señala a herramientas externas que el paquete tiene tipos
models.py
service.py
_stubs/ ← stubs propios si wrappeas una librería C
# users/_stubs/orm_legacy.pyi — stub mínimo para silenciar mypy en un módulo C
from typing import Any
class Connection:
def query(self, sql: str, params: tuple[Any, ...] = ...) -> list[dict[str, Any]]: ...
def close(self) -> None: ...
def connect(dsn: str) -> Connection: ...
La decisión de from __future__ import annotations en todos los archivos nuevos no es cosmética: activa la evaluación diferida (PEP 563), lo que permite escribir list[Invoice] sin que Python intente resolver Invoice en el momento de la definición de la clase. Sin eso, los forward references y los ciclos de importación se vuelven un problema real antes de que hayas tipado nada.
El bloque if TYPE_CHECKING: es el truco más poderoso de la estrategia gradual. Todo lo que importas ahí es invisible en runtime y completamente visible para mypy. Úsalo para romper ciclos que surgirían si el import fuera real, o para traer tipos de dependencias pesadas que no quieres pagar en tiempo de arranque.
# type: ignore merece respeto: es una herramienta, no una rendición. La diferencia entre deuda técnica documentada y deuda técnica oculta está en ese comentario que sigue: # type: ignore[return] # remove when orm-legacy gets stubs (issue #142). Con warn_unused_ignores = true en mypy, los ignores obsoletos se convierten en warnings activos, así que no se acumulan silenciosamente.
La configuración por módulo en mypy.ini es lo que permite la estrategia módulo a módulo: activas disallow_untyped_defs solo en los paquetes que ya terminaste de tipar. El resto sigue funcionando. Esto es crítico en proyectos de más de 50k líneas donde un --strict global generaría miles de errores de golpe.
El archivo py.typed es un marcador vacío definido en PEP 561. Cuando publicas un paquete con ese archivo presente, pip, mypy y Pyright saben que deben leer las anotaciones del paquete en lugar de buscar un stub package separado. Sin él, mypy trata tus exports como Any incluso si tienes anotaciones perfectas.
Errores que debes conocer
Error: Usar # type: ignore sin especificar el código de error, lo que silencia cualquier problema futuro en esa línea y hace imposible saber qué estabas ignorando originalmente.
# ❌ Wrong result = legacy_lib.compute(x) # type: ignore # ✅ Right result = legacy_lib.compute(x) # type: ignore[no-untyped-call] # issue #98
Mypy acepta [error-code] como parte del comentario y, con warn_unused_ignores, te avisa cuando ese código específico ya no se dispara en esa línea.
Error: Anotar parámetros con Any explícito para “pasar” mypy en funciones que aún no has analizado, cuando lo correcto es dejar la función sin anotar temporalmente.
# ❌ Wrong
def process(data: Any) -> Any:
return data["value"] * 2
# ✅ Right
def process(data): # sin anotar: mypy lo trata como Unknown, no propaga Any
return data["value"] * 2
Una función sin anotar no contamina el grafo de tipos; una función que devuelve Any explícito sí lo hace, propagando Any a todo código que la consuma.
Error: Activar ignore_missing_imports = true globalmente y olvidarlo, cuando lo correcto es hacerlo temporal y moverse a stubs o packages types-* conforme el ecosistema los provee.
# ❌ Wrong — ignorar siempre, para todo [tool.mypy] ignore_missing_imports = true # ✅ Right — solo para los que genuinamente no tienen stubs todavía [[tool.mypy.overrides]] module = ["some_c_extension.*"] ignore_missing_imports = true
La mayoría de librerías populares tienen ya su paquete types-requests, types-PyYAML, etc. en PyPI; instalarlos es un pip install types-requests y mypy los detecta automáticamente.