mypy, pyright y pyrefly: verificadores estáticos en la práctica

Los type checkers no ejecutan tu código — razonan sobre él. Leen tus anotaciones, infieren lo que no está anotado, y te dicen dónde el sistema de tipos no puede garantizar corrección. La diferencia entre ellos no es ideológica: es de arquitectura, velocidad y cuánto ruido están dispuestos a tolerar.

La trinidad actual

mypy es el verificador original, creado por Jukka Lehtosalo y adoptado por Guido como la implementación de referencia de PEP 484. Es maduro, tiene el ecosistema de stubs más amplio (typeshed, stubs de terceros en PyPI como types-requests), y su comportamiento es el que define el “estándar de facto” cuando hay ambigüedad en el spec. Su talón de Aquiles: está escrito en Python, lo cual lo hace notablemente lento en proyectos grandes.

pyright viene de Microsoft y es la base del servidor de lenguaje Pylance que usa VS Code. Está escrito en TypeScript y corre sobre Node.js, lo que lo hace 5-10× más rápido que mypy en proyectos medianos. Su inferencia es más agresiva — a veces captura errores que mypy deja pasar, pero también a veces es más estricto de maneras que te sorprenden. La diferencia más visible: pyright sigue PEP 484 de forma más literal, mientras mypy a veces tiene “opiniones” propias.

pyrefly es el recién llegado de Meta (2025), escrito en Rust. Su propuesta es velocidad extrema comparable a un linter, con diseño pensado para monorepos masivos. Al momento de escritura está en beta activa y no cubre todos los casos esquina que mypy y pyright manejan, pero si tu proyecto crece mucho, vale la pena seguirlo.

Para CI en 2025, la recomendación práctica es: mypy o pyright según tu stack. Si usas VS Code con Pylance, pyright te da consistencia entre el editor y CI. Si tienes muchos stubs de terceros o usas frameworks que generan tipos dinámicamente (SQLAlchemy, Django con django-stubs), mypy suele tener mejor soporte.

Configuración en pyproject.toml

Ninguno de los tres se configura bien solo con flags en la línea de comandos. El lugar correcto es pyproject.toml:

# pyproject.toml

[tool.mypy]
python_version = "3.12"
# Punto de entrada — mypy verifica estos paquetes y sus dependencias
files = ["src", "tests"]

# Modo de strictness progresivo: empieza aquí y sube gradualmente
warn_return_any = true
warn_unused_ignores = true        # avisa si un "# type: ignore" ya no es necesario
warn_redundant_casts = true
disallow_untyped_defs = true      # funciones deben tener anotaciones completas
disallow_any_generics = true      # List en vez de List[str] es un error
check_untyped_defs = true         # verifica también el cuerpo de funciones sin anotar

# Para módulos de terceros sin stubs disponibles:
[[tool.mypy.overrides]]
module = ["some_untyped_lib.*", "another_lib"]
ignore_missing_imports = true

[tool.pyright]
include = ["src", "tests"]
pythonVersion = "3.12"
# "basic" | "standard" | "strict"
typeCheckingMode = "standard"

Si usas pyright en CI sin VS Code, instalas pyright desde PyPI y listo. Para mypy, mypy[reports] si quieres output en JUnit XML para tu CI.

Ejemplo realista: un pipeline de procesamiento

# src/pipeline.py
from __future__ import annotations

from collections.abc import Callable, Iterator
from typing import Generic, TypeVar

T = TypeVar("T")
U = TypeVar("U")


class Pipeline(Generic[T]):
    """Encadena transformaciones sobre un iterador de T."""

    def __init__(self, source: Iterator[T]) -> None:
        self._source = source

    def map(self, fn: Callable[[T], U]) -> Pipeline[U]:
        # Creamos un nuevo Pipeline con el tipo transformado.
        # mypy y pyright infieren U a partir de la firma de fn.
        def _transformed() -> Iterator[U]:
            for item in self._source:
                yield fn(item)

        return Pipeline(_transformed())

    def filter(self, predicate: Callable[[T], bool]) -> Pipeline[T]:
        def _filtered() -> Iterator[T]:
            for item in self._source:
                if predicate(item):
                    yield item

        return Pipeline(_filtered())

    def collect(self) -> list[T]:
        return list(self._source)


def parse_price(raw: str) -> float:
    # Puede lanzar ValueError — intencionalmente no lo suprimimos.
    return float(raw.strip().lstrip("$"))


def is_expensive(price: float) -> bool:
    return price > 100.0


# -------------------------------------------------------------------
# Uso: mypy y pyright deben inferir Pipeline[float] aquí sin ayuda.
# -------------------------------------------------------------------
def process_prices(raw_prices: list[str]) -> list[float]:
    return (
        Pipeline(iter(raw_prices))
        .map(parse_price)       # Pipeline[str] → Pipeline[float]
        .filter(is_expensive)   # Pipeline[float] → Pipeline[float]
        .collect()              # list[float]
    )


if __name__ == "__main__":
    prices = ["$50.00", "$150.00", "$200.00", "$30.00"]
    result = process_prices(prices)
    print(result)  # [150.0, 200.0]

Qué está pasando aquí

El punto más interesante es map. Devuelve Pipeline[U], donde U es un TypeVar independiente de T. Esto le dice al type checker: “el tipo de salida depende del tipo que retorne fn, no del tipo de entrada de Pipeline“. Cuando llamas .map(parse_price), mypy ve que parse_price: Callable[[str], float], sustituye U = float, y el resultado tiene tipo Pipeline[float].

El from __future__ import annotations al principio es clave si usas Python 3.9 o 3.10 — hace que todas las anotaciones sean strings evaluados lazily, lo que permite referencias forward y mejora el tiempo de import. En 3.12 ya no es estrictamente necesario pero no hace daño.

filter es más sencillo: preserva T porque la función predicado no cambia el tipo, solo decide qué elementos pasan. mypy verificará que no accidentalmente retornes Pipeline[bool] o algo incorrecto ahí.

La función process_prices no anota la cadena intermedia — eso es intencional. Los type checkers deben ser capaces de inferirlo completamente. Si pyright o mypy tienen problemas aquí, es una señal de que la inferencia de genéricos encadenados no funciona bien en esa versión.

Modos de strictness y cuándo subir

Tanto mypy como pyright tienen un modo --strict (mypy) o typeCheckingMode = "strict" (pyright). Activarlo en un proyecto existente sin anotaciones es una receta para 500 errores el primer día. La estrategia real:

  1. Empieza con check_untyped_defs = true en mypy (o basic en pyright) — verifica lo que ya está anotado.
  2. Añade disallow_untyped_defs = true — fuerza anotaciones en código nuevo.
  3. Usa warn_unused_ignores = true desde el principio — así los # type: ignore no se acumulan silenciosamente.
  4. Activa strict solo cuando el coverage de anotaciones es alto.

En CI, el comando mínimo es:

# Para mypy
mypy src/ --config-file pyproject.toml

# Para pyright
pyright --outputjson | jq '.generalDiagnostics[] | select(.severity == "error")'
# O simplemente:
pyright  # sale con código 1 si hay errores

Errores que debes conocer

Error: Usar # type: ignore sin especificar el código de error — silencia cualquier error presente y futuro en esa línea, incluyendo errores distintos al que querías suprimir.

# ❌ Wrong
result = some_untyped_function()  # type: ignore

# ✅ Right
result = some_untyped_function()  # type: ignore[no-untyped-call]

Con el código específico, warn_unused_ignores = true te avisa cuando el ignore ya no es necesario, y no estás ciegamente suprimiendo errores futuros.


Error: Configurar ignore_missing_imports = true globalmente en lugar de por módulo — esconde todos los imports sin stubs, incluyendo los de tu propio código si hay un problema de path.

# ❌ Wrong
[tool.mypy]
ignore_missing_imports = true

# ✅ Right
[[tool.mypy.overrides]]
module = ["boto3", "botocore.*"]
ignore_missing_imports = true

El override por módulo es quirúrgico: solo silencia la librería que sabes que no tiene stubs, y cualquier otro import faltante sigue siendo un error visible.


Error: Anotar con Any explícito para “resolver” un error de tipo — esto se propaga: cualquier operación sobre ese Any también devuelve Any, vaciando la utilidad del type checker aguas abajo.

from typing import Any

# ❌ Wrong — la función ya no contribuye información de tipo
def load_config(path: str) -> Any:
    import json
    return json.load(open(path))

# ✅ Right — TypedDict o un dataclass dan estructura verificable
from typing import TypedDict

class Config(TypedDict):
    host: str
    port: int
    debug: bool

def load_config(path: str) -> Config:
    import json
    with open(path) as f:
        return json.load(f)  # mypy acepta esto — json.load devuelve Any, que es compatible con Config en el return

La versión correcta documenta la forma esperada del JSON y permite que el type checker verifique cualquier código que use Config más adelante.


Control Block

130

Dejar un comentario

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

Scroll al inicio