Type hints en Python y verificación con mypy

1. Descripción general

Python es un lenguaje con tipado dinámico, lo que significa que no necesitas declarar el tipo de dato de antemano. Esto es flexible pero propenso a errores en tiempo de ejecución conforme el proyecto crece. Type hints (PEP 484) te permiten anotar tipos de datos sin cambiar el comportamiento en tiempo de ejecución, mientras que mypy lee esas anotaciones para detectar errores de tipo mientras escribes el código.

Después de leer este artículo, sabrás cómo escribir type hints para variables, funciones, clases, usar Optional, Union, TypedDict, genéricos, e integrar mypy en tu proceso de verificación de código.

2. Type hints básicos

2.1. Declarar tipos para variables y funciones

def calculate_total(price: float, quantity: int) -> float:
    return price * quantity

customer_name: str = "Juan García"
total: float = calculate_total(price=15.5, quantity=3)
print(total)
  • price: float, quantity: int son los tipos de los parámetros de entrada.
  • -> float es el tipo del valor devuelto.
  • customer_name: str es una anotación para la variable, que ayuda al IDE a sugerir código y a mypy a verificar.
46.5

Nota: Los type hints NO fuerzan tipos en tiempo de ejecución. Si pasas calculate_total("10", 2), Python seguirá ejecutándose normalmente porque el intérprete ignora las anotaciones — solo herramientas como mypy reportarán el error.

2.2. Instalar mypy

pip install mypy

Verificar un archivo:

mypy order_service.py

Si no hay errores, mypy imprime:

Success: no issues found in 1 source file

3. Optional y Union para valores que pueden ser None

Muchas funciones devuelven None cuando no encuentran datos. Usa Optional para expresar esto de manera explícita.

from typing import Optional

class Customer:
    def __init__(self, customer_id: int, email: str) -> None:
        self.customer_id = customer_id
        self.email = email

def find_customer(customer_id: int) -> Optional[Customer]:
    database = {1: Customer(1, "a@example.com")}
    return database.get(customer_id)

customer = find_customer(2)
if customer is not None:
    print(customer.email)
  • Optional[Customer] es equivalente a Union[Customer, None].
  • Si llamas a customer.email sin verificar None primero, mypy reportará el error Item "None" of "Optional[Customer]" has no attribute "email".
from typing import Union

def format_id(value: Union[int, str]) -> str:
    return str(value).zfill(6)

Union[int, str] permite que el parámetro sea int o str. A partir de Python 3.10, puedes escribir de manera más concisa usando int | str en lugar de Union[int, str] y Customer | None en lugar de Optional[Customer].

4. Tipos de colecciones: list, dict, tuple

from typing import Dict, List, Tuple

def summarize_orders(order_totals: List[float]) -> Tuple[float, int]:
    return sum(order_totals), len(order_totals)

def build_price_table(items: Dict[str, float]) -> Dict[str, float]:
    return {name: price * 1.1 for name, price in items.items()}
  • List[float] significa una lista de números flotantes.
  • Tuple[float, int] significa una tupla con exactamente 2 elementos: float y luego int.
  • Dict[str, float] es un diccionario con claves de tipo str y valores de tipo float.

Nota: A partir de Python 3.9, puedes usar directamente list[float], dict[str, float] sin necesidad de importar desde typing. Usa typing.List/typing.Dict principalmente para compatibilidad con Python 3.8 y versiones anteriores.

5. TypedDict para datos de diccionario con estructura

Cuando trabajas con JSON o diccionarios con esquema fijo, TypedDict ayuda a mypy a verificar cada clave.

from typing import TypedDict

class OrderPayload(TypedDict):
    order_id: int
    customer_email: str
    total: float

def create_order(payload: OrderPayload) -> str:
    return f"Pedido #{payload['order_id']} - {payload['customer_email']} - {payload['total']}"

payload: OrderPayload = {
    "order_id": 101,
    "customer_email": "b@example.com",
    "total": 250.0,
}
print(create_order(payload))

Si te falta la clave total o escribes mal "totl", mypy reporta:

error: Missing key "total" for TypedDict "OrderPayload"

Esta es una gran ventaja sobre usar Dict[str, Any] — controlas la estructura de datos mientras mantienes un diccionario Python puro en tiempo de ejecución.

6. Genéricos con TypeVar

Cuando escribes una función o clase que se usa con múltiples tipos de datos, usas TypeVar para mantener la relación entre el tipo de entrada y salida.

from typing import TypeVar, List

T = TypeVar("T")

def first_item(items: List[T]) -> T:
    return items[0]

first_name: str = first_item(["Carlos", "Ana"])
first_number: int = first_item([10, 20, 30])
  • TypeVar("T") crea un marcador de posición de tipo.
  • Cuando llamas first_item(["Carlos", "Ana"]), mypy deduce T = str, por lo que el resultado devuelto es str.
  • Cuando llamas con List[int], T = int. De esta manera, mypy sabe exactamente qué tipo se devuelve en cada llamada, en lugar de simplemente Any.

7. Class y Protocol en lugar de interface

Python no tiene interfaces como Java, pero Protocol (PEP 544) te permite definir tipado estructural — verificar “tiene el método correcto” en lugar de necesitar herencia.

from typing import Protocol

class PaymentGateway(Protocol):
    def charge(self, amount: float) -> bool:
        ...

class VnPayGateway:
    def charge(self, amount: float) -> bool:
        print(f"Cobrando {amount} a través de VNPay")
        return True

def process_payment(gateway: PaymentGateway, amount: float) -> bool:
    return gateway.charge(amount)

process_payment(VnPayGateway(), 199000.0)

VnPayGateway no necesita heredar de PaymentGateway — mypy la acepta si solo tiene el método charge(self, amount: float) -> bool que cumple con Protocol. Esta es una forma muy “Pythonic” de trabajar, manteniendo el duck typing pero teniendo verificación estática.

8. Configurar mypy para el proyecto

Añade la dependencia a requirements-dev.txt:

mypy==1.11.2

Crea el archivo de configuración mypy.ini en el directorio raíz:

[mypy]
python_version = 3.12
disallow_untyped_defs = True
warn_return_any = True
warn_unused_ignores = True
ignore_missing_imports = True
  • disallow_untyped_defs = True requiere que todas las funciones tengan type hints, evitando omisiones.
  • warn_return_any = True advierte cuando una función devuelve Any de manera silenciosa.
  • ignore_missing_imports = True es útil al usar librerías de terceros que aún no tienen type stubs.

Ejecuta la verificación en todo el proyecto:

mypy src/

9. Escribir tests para el comportamiento con type hints

Los type hints no reemplazan los tests, pero ayudan a que los tests sean más claros sobre los tipos de entrada/salida. Usa pytest:

<!-- equivalente a requirements-dev.txt, solo para ilustrar la estructura de dependencias -->
pytest==8.3.2
from order_service import calculate_total

def test_calculate_total_returns_correct_sum() -> None:
    result = calculate_total(price=20.0, quantity=5)

    assert result == 100.0

def test_calculate_total_with_zero_quantity() -> None:
    result = calculate_total(price=50.0, quantity=0)

    assert result == 0.0

Ejecuta los tests:

pytest -v
test_order_service.py::test_calculate_total_returns_correct_sum PASSED
test_order_service.py::test_calculate_total_with_zero_quantity PASSED

Puedes combinar ambos pasos en CI: ejecuta mypy primero para detectar errores de tipo, luego pytest para verificar la lógica de negocio. Ambas herramientas se complementan — mypy detecta errores de “tipo incorrecto”, pytest detecta errores de “lógica incorrecta”.

10. Errores comunes y cuándo no necesitas mypy

  • Olvidar actualizar type hints cuando cambias la lógica: por ejemplo, una función cambia de devolver Optional[Customer] a siempre devolver Customer, pero olvidas actualizar la anotación — mypy no reportará un error si el código sigue coincidiendo con el tipo más amplio anterior, resultando en información de anotación inexacta.
  • Usar Any en todas partes: declarar def process(data: Any) -> Any hace que mypy pierda su capacidad de verificación, deshabilitando efectivamente el tipado estático en esa sección.
  • Anotaciones incorrectas con librerías de terceros sin stubs: muchos paquetes antiguos aún no publican type stubs, mypy reportará error: Cannot find implementation or library stub. Puedes manejarlo con ignore_missing_imports o instalando types-<package> si está disponible.
  • Cuándo NO deberías usar mypy estrictamente: con scripts pequeños, scripts de un solo uso (throwaway scripts), o prototipos rápidos para probar ideas — requerir type hints completos puede ralentizar tu velocidad de escritura sin proporcionar valor correspondiente.

Nota: mypy solo hace análisis estático (static analysis) en el código fuente, no ejecuta el programa. No puede detectar errores que dependan de datos en tiempo de ejecución, por ejemplo, valores leídos de archivos de configuración o APIs externas que tengan el tipo correcto según se declaró.

11. Conclusión

Los type hints hacen que el código Python sea más legible, más fácil de mantener y reducen los errores por confusión de tipos, mientras que mypy convierte esas anotaciones en herramientas de verificación reales en lugar de solo documentación. Deberías comenzar aplicándolo gradualmente en funciones públicas, módulos críticos, luego expandirlo progresivamente a todo el proyecto usando la configuración disallow_untyped_defs. Integrar mypy en CI junto con tests usando pytest es la forma más práctica de mantener la flexibilidad de Python mientras logras seguridad cercana a la de un lenguaje con tipado estático.

pythontype-hintsmypystatic-typing