Type Checking Gradual en Python sin Romper tu Codebase

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.

Dejar un comentario

Tu dirección de correo electrónico no será publicada. Los campos obligatorios están marcados con *

Scroll al inicio