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
- Qué son los type hints y, sobre todo, qué NO hacen
- Sintaxis básica: variables, parámetros y valor de retorno
- Tipos genéricos modernos:
list[Libro],dict[str, Libro],tuple,set | NoneyOptional: el contrato debuscar_libro()Unioncon|: cuando algo puede ser varias cosas- Anotar
almacen.py: las firmas reales de Papyrus TypedDict: dar forma a las filas de CSV y JSONmypy: el verificador externo- 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:
precio: float— el parámetro debería ser unfloat.-> float— la función devuelve unfloat.
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 llamadaEl 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 = TrueEn 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":
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:
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):
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 impidevender("dos"). Para rechazar datos en ejecución siguen haciendo falta las guard clauses de 07-03. Los hints avisan antes; losraiseprotegen durante. - Olvidar el
| Noneen funciones que pueden no encontrar: sibuscar_librose anota-> Libro, mientes al lector y amypy, que dejará pasar elAttributeError. La anotación debe contar toda la verdad. lista secas en vez delist[Libro]: es legal pero pierde casi todo el valor — el editor no sabrá autocompletar los elementos.- Importar
List/Optionaldetypingpor inercia: en Python moderno (3.10+),list[...]yX | Noneson la forma preferida. Reconoce la vieja al leerla; escribe la nueva. - Consejo: ejecuta
mypycomo parte de tu rutina, igual que miraspapyrus.log. Un error demypyes 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
-
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
Nonesi 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 -
Define un
TypedDictllamadoFilaCatalogopara las filas del CSV del catálogo (clavestitulo,precio,stock, todasstrporque vienen deDictReader) y anota la funciónfila_a_libro(fila)que convierte una fila en unLibro(puede lanzarValueErrorsi el precio no es numérico — recuerda: eso va en la docstring, no en el->). -
Este código pasa desapercibido hasta que Omar pide un título agotado. ¿Qué error señalaría
mypyy cómo lo corregirías usando el contrato deerrores.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
-
def mas_caro(catalogo: dict[str, Libro]) -> str | None: if not catalogo: return None return max(catalogo.values(), key=lambda l: l.precio).tituloEl
-> str | Nonees obligatorio: hay un camino que devuelveNone. Anotar solo-> strsería mentir. -
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 quecierre_de_cajaprotege con sutrymínimo (07-02); la excepción posible se documenta en la docstring. -
mypyseñalaItem "None" of "Libro | None" has no attribute "hay_stock":buscar_libropuede devolverNoneyNone.hay_stock()explotaría. Dos arreglos coherentes con M7: comprobarif libro is None: raise LibroNoEncontradoError(titulo)antes de usarlo, o directamente llamar aobtener_libro(catalogo, titulo), cuya firma-> Libroya 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
- Introducción a Python
- Configuración del Entorno de Desarrollo
- Sintaxis de Python y Tipos de Datos Básicos
- Variables y Constantes
- Entrada y Salida Básica
- Entornos Virtuales y Gestión de Paquetes
Módulo 2: Estructuras de Control
- Sentencias Condicionales
- Bucles: for y while
- Herramientas de Control de Flujo
- Comprensiones de Listas
Módulo 3: Funciones y Módulos
- Definición de Funciones
- Argumentos de Función
- Funciones Lambda
- Módulos y Paquetes
- Visión General de la Biblioteca Estándar
Módulo 4: Estructuras de Datos
Módulo 5: Programación Orientada a Objetos
Módulo 6: Manejo de Archivos
- Lectura y Escritura de Archivos
- Trabajo con Archivos CSV
- Manejo de Datos JSON
- Operaciones con Archivos y Directorios
Módulo 7: Manejo de Errores y Excepciones
- Introducción a las Excepciones
- Manejo de Excepciones
- Lanzamiento de Excepciones
- Excepciones Personalizadas
- Buenas Prácticas y Registro de Errores con logging
Módulo 8: Temas Avanzados
- Anotaciones de Tipos (type hints)
- Decoradores
- Generadores
- Administradores de Contexto
- Concurrencia: Hilos y Procesos
- Asyncio para Programación Asíncrona
Módulo 9: Pruebas y Depuración
- Introducción a las Pruebas
- Pruebas Unitarias con unittest
- Pruebas con pytest
- Desarrollo Guiado por Pruebas
- Técnicas de Depuración
- Uso de pdb para Depuración
Módulo 10: Desarrollo Web con Python
- Introducción al Desarrollo Web
- Fundamentos del Framework Flask
- Construcción de APIs REST con Flask
- Introducción a Django
- Construcción de Aplicaciones Web con Django
Módulo 11: Ciencia de Datos con Python
- Introducción a la Ciencia de Datos
- NumPy para Computación Numérica
- Pandas para Manipulación de Datos
- Matplotlib para Visualización de Datos
- Introducción al Aprendizaje Automático con scikit-learn
