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