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:
- Empieza con
check_untyped_defs = trueen mypy (obasicen pyright) — verifica lo que ya está anotado. - Añade
disallow_untyped_defs = true— fuerza anotaciones en código nuevo. - Usa
warn_unused_ignores = truedesde el principio — así los# type: ignoreno se acumulan silenciosamente. - Activa
strictsolo 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
N° 130