El módulo 7 terminó con una tienda que ya no se cae: se explica. Pero para explicarse, Papyrus dependía de docstrings, de guard clauses y de tu memoria: ¿vender() devuelve el importe o el Libro? ¿buscar_libro() puede devolver None? Hasta ahora la respuesta vivía en tu cabeza o en un comentario. Las anotaciones de tipos (type hints) escriben esos contratos directamente en la firma de la función, donde el editor, las herramientas y el próximo lector — probablemente tú dentro de tres meses — pueden verlos. En esta lección también saldamos una deuda concreta: en la lección 05-06 dijimos que las anotaciones de las dataclasses "son type hints que se estudian en el módulo 8". Ese momento es ahora.

Contenido

  1. Qué son los type hints y, sobre todo, qué NO hacen
  2. Sintaxis básica: variables, parámetros y valor de retorno
  3. Tipos genéricos modernos: list[Libro], dict[str, Libro], tuple, set
  4. | None y Optional: el contrato de buscar_libro()
  5. Union con |: cuando algo puede ser varias cosas
  6. Anotar almacen.py: las firmas reales de Papyrus
  7. TypedDict: dar forma a las filas de CSV y JSON
  8. mypy: el verificador externo
  9. Beneficios reales y cuándo relajarse

Qué son los type hints (y qué NO hacen)

Una anotación de tipo es una etiqueta que declara qué tipo de valor se espera en una variable, un parámetro o un retorno:

def precio_con_iva(precio: float) -> float:
    return round(precio * 1.04, 2)
  • precio: float — el parámetro debería ser un float.
  • -> float — la función devuelve un float.

Y ahora lo más importante de toda la lección: Python no comprueba las anotaciones en tiempo de ejecución. Este código se ejecuta sin protestar:

precio_con_iva("gratis")   # TypeError, sí... pero por el *, no por la anotación
precio_con_iva([1, 2, 3])  # las anotaciones no impidieron la llamada

El error que verás es el mismo TypeError de siempre, producido al intentar multiplicar; la anotación ni lo provocó ni lo evitó. Las anotaciones son documentación estructurada: Python las guarda (en __annotations__) pero las ignora al ejecutar. Quien las aprovecha es el ecosistema: tu editor para autocompletar, y verificadores externos como mypy para detectar errores antes de ejecutar. Compáralo con lo que hacía el módulo 7:

Mecanismo Cuándo actúa Qué hace si el tipo es incorrecto
Guard clause (raise TypeError) de 07-03 En ejecución Detiene el programa con un error accionable
__post_init__ de Libro (05-06, 07-03) En ejecución, al crear el objeto Rechaza el dato inválido
Type hint Nunca en ejecución Nada — pero mypy y el editor avisan antes

No son alternativas: son capas. Los type hints detectan el error en tu pantalla mientras escribes; las guard clauses lo detienen si a pesar de todo llega en ejecución.

Sintaxis básica

Variables

IVA_LIBROS: float = 0.04
DESCUENTO_SOCIO: float = 0.05
nombre_tienda: str = "Papyrus"
unidades: int = 2
es_socio: bool = True

En variables locales con valor evidente (unidades = 2) la anotación suele sobrar: el tipo se infiere. Donde brilla es en constantes de módulo, en atributos y en variables que se inicializan "vacías":

titulos_agotados: list[str] = []   # sin la anotación, nadie sabe qué contendrá

Parámetros y retorno

def precio_final(precio: float, socio: bool = False) -> float:
    """Aplica IVA y, si procede, el descuento de socio."""
    precio_iva = precio * (1 + IVA_LIBROS)
    if socio:
        precio_iva *= (1 - DESCUENTO_SOCIO)
    return round(precio_iva, 2)

Fíjate en que el valor por defecto convive con la anotación: socio: bool = False. Una función que no devuelve nada se anota con -> None:

def registrar_alta(codigo: str) -> None:
    ...

Tipos genéricos modernos

Las colecciones se anotan indicando qué contienen, con corchetes. Desde Python 3.9 se usan los tipos integrados directamente (verás List y Dict importados de typing en código antiguo; hoy ya no hacen falta):

from modelos import Libro

catalogo: dict[str, Libro] = {}          # título → Libro
pendientes: list[str] = ["Fausto"]       # lista de títulos
venta: tuple[str, str, float] = ("2026-07-13", "Hamlet", 20.70)  # fila de ventas.csv
codigos_socio: set[str] = {"LUIS-001", "MARTA-002", "PAU-003"}
Anotación Se lee como Ejemplo en Papyrus
list[Libro] lista de objetos Libro resultado de cargar el catálogo CSV
dict[str, Libro] claves str, valores Libro el catálogo en memoria
tuple[str, str, float] tupla de exactamente 3 elementos con esos tipos una fila de ventas.csv
tuple[str, ...] tupla de longitud variable, todo str títulos inmutables
set[str] conjunto de str códigos de socio válidos

Y aquí se salda la promesa de 05-06: en la dataclass Libro, las líneas titulo: str, precio: float, stock: int = 0 son exactamente esto — anotaciones de tipo. @dataclass las lee de __annotations__ para generar __init__ y compañía. Es el único lugar de Python donde las anotaciones tienen un efecto práctico directo... y aun así, en ejecución nadie verifica que precio sea de verdad un float: por eso Libro necesita su __post_init__.

| None: el contrato de buscar_libro()

En el módulo 7 diseñamos dos funciones hermanas con contratos distintos: buscar_libro() devuelve el libro o None (la ausencia es un caso normal), y obtener_libro() devuelve el libro o lanza LibroNoEncontradoError. Ese contrato, que entonces defendíamos en la docstring, ahora se escribe en la firma:

def buscar_libro(catalogo: dict[str, Libro], titulo: str) -> Libro | None:
    """Devuelve el Libro o None si no está en el catálogo."""
    return catalogo.get(titulo)

Libro | None se lee "un Libro o None". Es la forma moderna (Python 3.10+) de Optional[Libro], que aún verás en muchísimo código:

from typing import Optional

def buscar_libro(catalogo: dict[str, Libro], titulo: str) -> Optional[Libro]: ...

Son equivalentes. La ventaja práctica: cuando escribes buscar_libro(catalogo, "Hamlet").precio_final(), un verificador te avisa de que el resultado podría ser None y que None no tiene precio_final() — el clásico AttributeError de las 20:05 con la tienda cerrada, detectado antes de ejecutar.

Union con |

El mismo operador sirve para cualquier unión de tipos, no solo con None:

def cargar_config(clave: str) -> str | float | bool:
    """Devuelve el valor de config.json, que puede ser texto, número o booleano."""
    ...

Úsalo con moderación: una función que devuelve str | float | bool | None está pidiendo a gritos un rediseño. Los contratos claros de M7 (una función, un tipo de resultado, o una excepción) siguen siendo la mejor guía.

Anotar almacen.py: las firmas reales

Así quedan las cabeceras de almacen.py con sus contratos a la vista. Compara cada firma con lo que la función ya hacía en M6 y M7 — no cambia ni una línea de lógica:

from pathlib import Path
from modelos import Libro

BASE: Path = Path(__file__).parent

def buscar_libro(catalogo: dict[str, Libro], titulo: str) -> Libro | None: ...

def obtener_libro(catalogo: dict[str, Libro], titulo: str) -> Libro:
    """Devuelve el Libro o lanza LibroNoEncontradoError."""

def vender(catalogo: dict[str, Libro], titulo: str, unidades: int) -> float:
    """Descuenta stock y devuelve el importe con IVA (p. ej. Hamlet x2 → 20.70)."""

def reponer(catalogo: dict[str, Libro], titulo: str, unidades: int) -> None: ...

def cargar_catalogo(ruta: Path) -> dict[str, Libro]: ...

def guardar_catalogo(catalogo: dict[str, Libro], ruta: Path) -> None: ...

def cierre_de_caja(ruta_ventas: Path) -> float:
    """Suma los importes del día; salta filas corruptas y las registra a WARNING."""

Observa cuánta historia del curso cabe en una firma: obtener_libro no anota Libro | None porque su contrato es lanzar, no devolver None — la excepción no forma parte de la anotación de retorno (se documenta en la docstring). Y vender devuelve float: el importe, no el libro. Dudas que en M7 resolvías releyendo el cuerpo, ahora se responden sin abrir la función.

TypedDict: forma para las filas de CSV y JSON

csv.DictReader (M6) devuelve diccionarios, y dict[str, str] dice poco: ¿qué claves? TypedDict declara la forma exacta:

from typing import TypedDict

class FilaVenta(TypedDict):
    fecha: str
    titulo: str
    importe: str   # DictReader siempre entrega str; convertir es cosa nuestra (07-02)

def parsear_fila(fila: FilaVenta) -> float:
    return float(fila["importe"])

Con esto, fila["importr"] (errata incluida) deja de ser una bomba de relojería KeyError: mypy la marca al instante. Es una pincelada — suficiente para las filas de ventas.csv y los objetos de catalogo.json; no necesitas más por ahora.

mypy: el verificador externo

Si Python no comprueba las anotaciones, algo tiene que hacerlo. Ese algo es mypy, que se instala en el venv del proyecto como cualquier paquete (M1):

pip install mypy
mypy almacen.py

Ejemplo real del bug que caza. Este código se ejecuta la mayoría de las veces... hasta que el libro no existe:

def precio_para_julia(catalogo: dict[str, Libro], titulo: str) -> float:
    libro = buscar_libro(catalogo, titulo)
    return libro.precio_final(socio=False)   # ¿y si libro es None?
almacen.py:42: error: Item "None" of "Libro | None" has no attribute
"precio_final"  [union-attr]
Found 1 error in 1 file (checked 1 source file)

mypy no ejecutó nada: leyó las firmas y dedujo que ese AttributeError era posible. El arreglo es el patrón LBYL/EAFP de 07-01, y ahora la herramienta te obliga a elegirlo conscientemente:

libro = buscar_libro(catalogo, titulo)
if libro is None:
    raise LibroNoEncontradoError(titulo)
return libro.precio_final(socio=False)

Tras el if, mypy estrecha el tipo: sabe que libro ya es Libro. A esto se le llama narrowing y es la razón por la que anotar | None compensa siempre.

Beneficios y cuándo relajarse

Beneficios: documentación viva que no se desactualiza como los comentarios (si cambias la función y no la firma, mypy protesta); autocompletado preciso en el editor (escribe libro. y aparecen precio_final y hay_stock); y bugs detectados antes de ejecutar, como el None de arriba.

Cuándo relajarse: en un script de veinte líneas para renombrar los archivos de copias/, anotar cada variable es burocracia. La regla práctica: anota siempre las firmas públicas de tus módulos (almacen.py, modelos.py, errores.py — sus atributos como solicitado: int y disponible: int también agradecen tipos); relájate en scripts desechables y variables locales obvias. Los type hints son graduales por diseño: puedes anotar un módulo hoy y otro el mes que viene.

Errores Comunes y Consejos

  • Creer que las anotaciones validan: def vender(unidades: int) no impide vender("dos"). Para rechazar datos en ejecución siguen haciendo falta las guard clauses de 07-03. Los hints avisan antes; los raise protegen durante.
  • Olvidar el | None en funciones que pueden no encontrar: si buscar_libro se anota -> Libro, mientes al lector y a mypy, que dejará pasar el AttributeError. La anotación debe contar toda la verdad.
  • list a secas en vez de list[Libro]: es legal pero pierde casi todo el valor — el editor no sabrá autocompletar los elementos.
  • Importar List/Optional de typing por inercia: en Python moderno (3.10+), list[...] y X | None son la forma preferida. Reconoce la vieja al leerla; escribe la nueva.
  • Consejo: ejecuta mypy como parte de tu rutina, igual que miras papyrus.log. Un error de mypy es un bug que no llegó a producción.
  • Consejo: cuando una firma anotada te quede larguísima o llena de |, no pelees con la sintaxis — suele ser la firma la que pide simplificar la función.

Ejercicios

  1. Anota completamente esta función de Papyrus (parámetros y retorno), sabiendo que devuelve el título del libro más caro del catálogo o None si el catálogo está vacío:

    def mas_caro(catalogo):
        if not catalogo:
            return None
        return max(catalogo.values(), key=lambda l: l.precio).titulo
    
  2. Define un TypedDict llamado FilaCatalogo para las filas del CSV del catálogo (claves titulo, precio, stock, todas str porque vienen de DictReader) y anota la función fila_a_libro(fila) que convierte una fila en un Libro (puede lanzar ValueError si el precio no es numérico — recuerda: eso va en la docstring, no en el ->).

  3. Este código pasa desapercibido hasta que Omar pide un título agotado. ¿Qué error señalaría mypy y cómo lo corregirías usando el contrato de errores.py?

    def cobrar(catalogo: dict[str, Libro], titulo: str) -> float:
        libro = buscar_libro(catalogo, titulo)
        if not libro.hay_stock():
            raise StockInsuficienteError(titulo, 1, 0)
        return libro.precio_final()
    

Soluciones

  1. def mas_caro(catalogo: dict[str, Libro]) -> str | None:
        if not catalogo:
            return None
        return max(catalogo.values(), key=lambda l: l.precio).titulo
    

    El -> str | None es obligatorio: hay un camino que devuelve None. Anotar solo -> str sería mentir.

  2. from typing import TypedDict
    
    class FilaCatalogo(TypedDict):
        titulo: str
        precio: str
        stock: str
    
    def fila_a_libro(fila: FilaCatalogo) -> Libro:
        """Convierte una fila de CSV en Libro. Lanza ValueError si precio/stock no son numéricos."""
        return Libro(fila["titulo"], float(fila["precio"]), int(fila["stock"]))
    

    Las conversiones float()/int() son las mismas que cierre_de_caja protege con su try mínimo (07-02); la excepción posible se documenta en la docstring.

  3. mypy señala Item "None" of "Libro | None" has no attribute "hay_stock": buscar_libro puede devolver None y None.hay_stock() explotaría. Dos arreglos coherentes con M7: comprobar if libro is None: raise LibroNoEncontradoError(titulo) antes de usarlo, o directamente llamar a obtener_libro(catalogo, titulo), cuya firma -> Libro ya garantiza (lanzando si hace falta) que hay libro. La segunda es mejor: reutiliza el contrato existente.

Conclusión

Los type hints escriben en las firmas los contratos que el módulo 7 defendía a mano: buscar_libro() -> Libro | None cuenta la verdad completa, vender() -> float despeja la duda del importe, y las anotaciones de la dataclass Libro — la promesa pendiente de 05-06 — por fin tienen nombre y apellidos. Python no las comprueba en ejecución; mypy y tu editor las convierten en una red que caza bugs antes de que Júlia o Omar los sufran. Con los contratos ya visibles, el siguiente paso ataca otra repetición: en Papyrus queremos cronometrar cierre_de_caja, auditar cada venta en el log de M7 y reintentar operaciones frágiles — sin copiar y pegar el mismo código alrededor de cada función. Eso es exactamente lo que resuelven los decoradores, y son la próxima lección.

Curso de Programación en Python

Módulo 1: Introducción a Python

Módulo 2: Estructuras de Control

Módulo 3: Funciones y Módulos

Módulo 4: Estructuras de Datos

Módulo 5: Programación Orientada a Objetos

Módulo 6: Manejo de Archivos

Módulo 7: Manejo de Errores y Excepciones

Módulo 8: Temas Avanzados

Módulo 9: Pruebas y Depuración

Módulo 10: Desarrollo Web con Python

Módulo 11: Ciencia de Datos con Python

Módulo 12: Proyecto Final

© Copyright 2026. Todos los derechos reservados