20. Tipado gradual con type hints y revisión básica con mypy

20.1 Objetivo del tema

Python es un lenguaje dinámico: no exige declarar tipos para ejecutar un programa. Sin embargo, las anotaciones de tipo ayudan a comunicar intención, documentar contratos y detectar errores antes de ejecutar.

En este tema usaremos type hints de forma gradual y revisaremos el código con mypy. El objetivo no es convertir Python en otro lenguaje, sino mejorar claridad y reducir errores comunes.

Objetivo práctico: agregar anotaciones de tipo útiles a funciones Python y usar mypy para detectar inconsistencias básicas.

20.2 Qué son los type hints

Los type hints son anotaciones que indican qué tipos se esperan en parámetros, variables o valores de retorno.

def calcular_importe(precio: float, cantidad: int) -> float:
    return precio * cantidad

Python no impide ejecutar la función con otros tipos. Las anotaciones sirven para herramientas, editores y lectores del código.

20.3 Por qué ayudan a la calidad

Las anotaciones de tipo ayudan porque hacen explícito el contrato de una función. Al leer la firma, sabemos qué datos espera y qué devuelve.

  • Mejoran autocompletado en editores.
  • Documentan expectativas sin comentarios extra.
  • Ayudan a detectar llamadas incorrectas.
  • Reducen confusión en estructuras de datos.
  • Facilitan refactorizaciones pequeñas.

20.4 Tipado gradual

Tipado gradual significa que no hace falta anotar todo el proyecto de una vez. Podemos empezar por funciones importantes, modelos de datos y límites entre módulos.

Una estrategia razonable:

  • Anotar funciones nuevas.
  • Anotar funciones públicas o compartidas.
  • Anotar dataclasses y modelos de datos.
  • Evitar forzar anotaciones complejas en código que todavía cambia mucho.

20.5 Tipos básicos

Algunos tipos frecuentes:

def saludar(nombre: str) -> str:
    return f"Hola, {nombre}"


def es_mayor_de_edad(edad: int) -> bool:
    return edad >= 18


def calcular_total(precio: float, cantidad: int) -> float:
    return precio * cantidad

La flecha -> indica el tipo de retorno.

20.6 Listas y diccionarios

En Python moderno podemos escribir tipos genéricos con list y dict.

def sumar_valores(valores: list[float]) -> float:
    return sum(valores)


def obtener_email(usuario: dict[str, str]) -> str:
    return usuario["email"]

Los diccionarios con muchas claves suelen ser difíciles de tipar con precisión. En esos casos, una dataclass puede ser más clara.

20.7 Tipar dataclasses

Las dataclasses combinan muy bien con type hints.

from dataclasses import dataclass


@dataclass(frozen=True)
class Producto:
    precio: float
    cantidad: int


def calcular_importe(producto: Producto) -> float:
    return producto.precio * producto.cantidad

La estructura del dato y la firma de la función quedan claras.

20.8 Valores opcionales

Cuando un valor puede ser None, debemos indicarlo.

def buscar_producto(codigo: str) -> Producto | None:
    if codigo == "A1":
        return Producto(precio=1000, cantidad=1)
    return None

Quien llama debe manejar el caso None:

producto = buscar_producto("A1")
if producto is not None:
    print(producto.precio)

20.9 Instalar mypy

Activa el entorno virtual del proyecto y ejecuta:

python -m pip install mypy

Verifica la instalación:

python -m mypy --version

20.10 Ejecutar mypy

Ejecuta mypy sobre la carpeta src:

python -m mypy src

Si el proyecto todavía tiene pocas anotaciones, mypy puede informar pocos problemas. A medida que agregamos tipos, la herramienta puede revisar más contratos.

20.11 Configurar mypy en pyproject.toml

Agrega una configuración inicial:

[tool.mypy]
python_version = "3.10"
warn_return_any = true
warn_unused_configs = true
disallow_untyped_defs = false
check_untyped_defs = true

Esta configuración permite empezar gradualmente. Más adelante se puede volver más estricta.

20.12 Ejemplo de error detectado

Crea src/tipos_demo.py:

def calcular_importe(precio: float, cantidad: int) -> float:
    return precio * cantidad


total = calcular_importe("1000", 2)

Ejecuta:

python -m mypy src/tipos_demo.py

mypy debería señalar que el primer argumento espera float, pero recibe str.

20.13 Aplicación sobre ventas_demo

Si ya creaste dataclasses en modelos.py, puedes anotar funciones de venta:

from modelos import Producto


def calcular_subtotal(productos: list[Producto]) -> float:
    return sum(
        producto.precio * producto.cantidad
        for producto in productos
    )


def obtener_descuento(tipo_cliente: str) -> float:
    return {"vip": 0.15, "regular": 0.05}.get(tipo_cliente, 0)

Las firmas comunican claramente qué datos necesita cada función.

20.14 Evitar Any innecesario

Any desactiva gran parte del beneficio del tipado. A veces es necesario, pero conviene no usarlo como salida rápida.

from typing import Any


def procesar(datos: Any) -> Any:
    return datos["valor"] * 2

Si conocemos la estructura, es mejor expresarla:

def duplicar_valor(datos: dict[str, float]) -> float:
    return datos["valor"] * 2

20.15 Type aliases

Un alias de tipo puede mejorar la lectura cuando una estructura se repite.

ProductoDict = dict[str, float]


def calcular_importe(producto: ProductoDict) -> float:
    return producto["precio"] * producto["cantidad"]

Si la estructura crece, una dataclass suele ser mejor que un alias de diccionario.

20.16 Protocolos simples

En casos más avanzados, un protocolo permite indicar que esperamos un objeto con cierto método, sin depender de una clase concreta.

from typing import Protocol


class CalculaTotal(Protocol):
    def calcular_total(self) -> float:
        ...


def generar_ticket(venta: CalculaTotal) -> str:
    return f"Total: {venta.calcular_total()}"

No hace falta empezar por protocolos. Son útiles cuando el proyecto ya tiene varias clases con el mismo contrato.

20.17 Tipos y pruebas

mypy no reemplaza las pruebas. Los tipos ayudan a detectar inconsistencias estructurales; las pruebas verifican comportamiento.

python -m mypy src
python -m pytest

Ambas herramientas se complementan. Un código puede pasar mypy y tener una regla de negocio incorrecta.

20.18 Ejercicio guiado

Agrega tipos a este código:

def aplicar_descuento(total, descuento):
    return total * (1 - descuento)


def obtener_descuento(cliente):
    if cliente == "vip":
        return 0.15
    return 0

Una posible solución:

def aplicar_descuento(total: float, descuento: float) -> float:
    return total * (1 - descuento)


def obtener_descuento(cliente: str) -> float:
    if cliente == "vip":
        return 0.15
    return 0

20.19 Ejercicio propuesto

En ventas_demo, realiza estas tareas:

  • Instala mypy.
  • Agrega configuración básica en pyproject.toml.
  • Anota al menos tres funciones de ventas.py.
  • Anota tus dataclasses si ya existen.
  • Ejecuta mypy, Ruff, Black y las pruebas.
python -m mypy src
python -m ruff check src tests
python -m black src tests
python -m pytest

20.20 Lista de verificación

Antes de continuar, verifica que puedes hacer lo siguiente:

  • Anotar parámetros y valores de retorno.
  • Usar tipos básicos como str, int, float y bool.
  • Anotar listas y diccionarios simples.
  • Representar valores opcionales con | None.
  • Instalar y ejecutar mypy.
  • Configurar mypy de forma gradual.
  • Combinar mypy con pruebas automatizadas.

20.21 Conclusión

En este tema vimos que los type hints ayudan a expresar contratos y mejorar la comprensión del código. mypy permite revisar esas anotaciones y detectar inconsistencias antes de ejecutar el programa.

En el próximo tema trabajaremos con interfaces claras: funciones, módulos y contratos fáciles de entender.