Type hints y dataclasses
Anotar tipos para que el editor y mypy te avisen antes de ejecutar, y generar clases de datos sin repetir código
Anotar tipos no cambia lo que hace el programa al ejecutarse: cambia lo que puedes saber antes de ejecutarlo. Aprenderás a anotar funciones y estructuras, a leer los avisos de mypy, y a usar dataclasses para crear clases de datos con __init__, __repr__ y comparación sin escribirlos a mano.
- Anotar variables, funciones y contenedores con la sintaxis de Python 3.9
- Distinguir Optional, Union y los tipos genéricos como list[float]
- Explicar qué comprueba mypy y qué no comprueba Python en tiempo de ejecución
- Crear dataclasses con field, frozen, orden y validación en __post_init__
- Decidir cuándo merece la pena tipar y cuándo es ruido
Antes necesitas: Clases y objetos · Composición y diseño de clases
En esta lección
- ¿Qué es una anotación de tipo?
- ¿Para qué sirve tipar?
- ¿Cómo funciona por dentro?
- Sintaxis: variables, funciones y contenedores
- Ejemplo básico: anotar una función
- Ejemplo intermedio: cuando falta un dato
- Dataclasses: datos con estructura
- Caso práctico: tipar un pipeline de datos
- Visualización: qué genera @dataclass
- Errores comunes
- Buenas prácticas: cuándo tipar y cuánto
- Ejercicios
- Soluciones explicadas
- Resumen
- Evaluación
Cuando una función recibe datos y devuelve resultado, el nombre no te dice si datos es una lista de diccionarios, un DataFrame o una ruta. Tienes que leer el cuerpo entero para averiguarlo. Las anotaciones de tipo existen para responder esa pregunta en la firma, antes de ejecutar nada.
Python no las usa para ejecutar: son información para personas, editores y herramientas como mypy. Combinadas con dataclasses, convierten una clase de datos de treinta líneas de __init__ y __repr__ en cinco declaraciones.
¿Qué es una anotación de tipo?
Una anotación es una expresión que escribes junto a un nombre o a un parámetro para declarar qué tipo esperas. nombre: str dice que nombre debería contener un texto; -> float dice que la función devuelve un número decimal. El intérprete evalúa la anotación al definir la función y la guarda en __annotations__, pero no la comprueba.
Esa distinción es la clave de todo el tema. Python sigue siendo dinámico: Venta("teclado", "tres", 120.0) se ejecuta sin quejarse aunque unidades deba ser int. Lo que cambia es que tu editor subraya el error y mypy lo reporta antes de que llegue a producción.
¿Para qué sirve tipar?
- Documentar sin comentarios: la firma dice qué entra y qué sale, y el editor lo muestra al pasar el ratón.
- Detectar errores antes: mypy encuentra llamadas con argumentos cambiados,
Noneinesperados y retornos incoherentes. - Autocompletado real: si el editor sabe que
ventaes unaVenta, te ofrece sus atributos y métodos. - Refactorizar con red: al renombrar o cambiar una firma, las herramientas encuentran los usos incompatibles.
- Diseñar mejor: obliga a decidir qué representa cada dato y qué puede ser
None.
¿Cómo funciona por dentro?
Al definir una función, Python evalúa las anotaciones y las acumula en el diccionario __annotations__ de la función. Las de una clase van a Clase.__annotations__. Nada más ocurre en tiempo de ejecución: no hay conversión, no hay validación, no hay penalización apreciable.
mypy es un analizador estático: lee el código sin ejecutarlo, construye el árbol de tipos y comprueba compatibilidades. Por eso puede avisar de una rama que nunca se ejecuta en tus pruebas. Es más estricto cuanto más código anotado encuentra; en zonas sin anotar asume Any y deja pasar casi todo, que es justo el agujero que el tipado gradual intenta cerrar poco a poco.
Una anotación puede ser cualquier expresión que se evalúe a un tipo: int, str, list[float], dict[str, int], Optional[float]. Desde Python 3.9 los contenedores de la biblioteca estándar admiten la sintaxis con corchetes (list[int]); antes había que importar List de typing. En 3.10 llegó X | None como forma corta de Optional[X].
Sintaxis: variables, funciones y contenedores
from typing import Dict, List, Optional, Union
# Variables
precios: List[float] = [120.0, 45.5]
catalogo: Dict[str, float] = {"teclado": 120.0}
nota: Optional[float] = None # float o None
identificador: Union[int, str] = "A-12"
def total(precios: List[float], descuento: float = 0.0) -> float:
return round(sum(precios) * (1 - descuento), 2)
print(total(precios, 0.1))
print(total.__annotations__)| Anotación | Significa | Ejemplo de valor válido |
|---|---|---|
int | Número entero | 3 |
Optional[float] | Un float o None | None, 2.5 |
Union[int, str] | Entero o texto | 7, "A-12" |
list[float] | Lista de decimales | [1.0, 2.5] |
dict[str, int] | Diccionario texto → entero | {"a": 1} |
Any | Cualquier cosa; desactiva la comprobación | None, 42 |
Optional[float] no significa «parámetro opcional»: significa «puede ser un float o None». Un parámetro con valor por defecto es otra cosa. Confundir ambas es habitual y lleva a funciones que devuelven None sin que el llamador lo espere.
Ejemplo básico: anotar una función
Tres anotaciones bastan para que la función se explique sola: dos parámetros y el retorno.
def precio_con_igv(precio: float, tasa: float = 0.18) -> float:
"""Devuelve el precio con el impuesto aplicado."""
return round(precio * (1 + tasa), 2)
print(precio_con_igv(120.0))
print(precio_con_igv(120.0, 0.0))
print(precio_con_igv.__annotations__)La salida es 141.6, 120.0 y un diccionario con las tres anotaciones. La función no se comporta distinto por estar anotada: precio_con_igv("120") daría TypeError: can't multiply sequence by non-int of type 'float' igual que antes. La anotación es un contrato declarado, no una aduana.
Ejemplo intermedio: cuando falta un dato
El caso más frecuente en datos reales: una función que busca algo y puede no encontrarlo. La anotación correcta es Optional[...], y quien la llama está obligado a contemplar el None.
from typing import Dict, Optional
def buscar_precio(catalogo: Dict[str, float], nombre: str) -> Optional[float]:
"""Devuelve el precio o None si el producto no está en el catálogo."""
return catalogo.get(nombre)
catalogo = {"teclado": 120.0, "mouse": 45.5}
for producto in ["teclado", "monitor"]:
precio = buscar_precio(catalogo, producto)
if precio is None:
print(f"{producto}: sin precio")
else:
print(f"{producto}: {precio:.2f}")La salida es teclado: 120.00 y monitor: sin precio. La comprobación if precio is None no es paranoia: es la consecuencia directa de la anotación. mypy, de hecho, se queja si usas precio * 2 antes de descartar el None.
Dataclasses: datos con estructura
Una @dataclass es una clase cuyos campos se declaran con anotaciones. El decorador genera por ti __init__, __repr__ y __eq__ a partir de esos campos. Lo que antes era un __init__ con cinco asignaciones y un __repr__ manual ahora son cinco líneas.
from dataclasses import dataclass, field
from typing import List
@dataclass
class Venta:
producto: str
unidades: int
precio: float
etiquetas: List[str] = field(default_factory=list)
def total(self) -> float:
return round(self.unidades * self.precio, 2)
v = Venta("teclado", 3, 120.0)
v.etiquetas.append("oferta")
print(v)
print(v.total())La salida es Venta(producto='teclado', unidades=3, precio=120.0, etiquetas=['oferta']) y 360.0. field(default_factory=list) es obligatorio para valores mutables: escribir etiquetas: List[str] = [] lanza ValueError: mutable default <class 'list'> for field etiquetas is not allowed: use default_factory, porque esa única lista se compartiría entre todas las instancias.
Con frozen=True la instancia es inmutable —asignar un campo lanza FrozenInstanceError— y se vuelve hashable, así que puede ir en un set o usarse como clave. Con order=True se generan las comparaciones <, <=, > y >= comparando los campos en el orden en que los declaraste.
from dataclasses import dataclass
@dataclass(frozen=True, order=True)
class Punto:
x: float
y: float
p = Punto(2, 3)
print(p == Punto(2, 3))
print(Punto(1, 2) < Punto(2, 0))
try:
p.x = 5
except Exception as error:
print(type(error).__name__, "->", error)La salida es True, True y FrozenInstanceError -> cannot assign to field 'x'. La comparación Punto(1, 2) < Punto(2, 0) compara primero x y solo mira y si empatan, como una tupla. Si necesitas un orden distinto, escribe tus propios métodos en lugar de order=True.
Las dataclasses no validan por sí solas: comprueban tipos solo si tú lo pides. Para reglas de negocio usa __post_init__, que se ejecuta al final del __init__ generado:
from dataclasses import dataclass
@dataclass
class Rango:
minimo: int
maximo: int
def __post_init__(self) -> None:
if self.minimo > self.maximo:
raise ValueError(f"rango inválido: {self.minimo} > {self.maximo}")
print(Rango(1, 5))
try:
Rango(9, 2)
except ValueError as error:
print("ValueError:", error)Caso práctico: tipar un pipeline de datos
Una tienda recibe filas de un CSV como diccionarios de texto. El pipeline tiene tres etapas: convertir cada fila en un objeto Venta validado, agregar por sede y devolver un resumen. Las anotaciones hacen visibles los contratos entre etapas.
from dataclasses import dataclass, field
from typing import Dict, Iterable, List
@dataclass(frozen=True)
class Venta:
producto: str
sede: str
unidades: int
precio: float
@property
def importe(self) -> float:
return round(self.unidades * self.precio, 2)
@dataclass
class Resumen:
por_sede: Dict[str, float] = field(default_factory=dict)
def agregar(self, venta: Venta) -> None:
previo = self.por_sede.get(venta.sede, 0.0)
self.por_sede[venta.sede] = round(previo + venta.importe, 2)
def cargar(filas: Iterable[Dict[str, str]]) -> List[Venta]:
"""Convierte texto del CSV en objetos validados."""
return [
Venta(fila["producto"], fila["sede"], int(fila["unidades"]), float(fila["precio"]))
for fila in filas
]
def resumir(ventas: Iterable[Venta]) -> Resumen:
resumen = Resumen()
for venta in ventas:
resumen.agregar(venta)
return resumen
filas = [
{"producto": "teclado", "sede": "Lima", "unidades": "3", "precio": "120.0"},
{"producto": "monitor", "sede": "Lima", "unidades": "1", "precio": "900.0"},
{"producto": "mouse", "sede": "Arequipa", "unidades": "5", "precio": "45.5"},
]
print(resumir(cargar(filas)))La salida es Resumen(por_sede={'Lima': 1260.0, 'Arequipa': 227.5}). Cada frontera está declarada: cargar recibe texto y devuelve Venta; resumir recibe Venta y devuelve Resumen. Si alguien cambia unidades a texto dentro de Venta, mypy lo detecta en importe sin ejecutar el programa.
Iterable es más flexible que List: acepta listas, generadores o cualquier cosa que se pueda recorrer. Declarar el parámetro como List obligaría a quien llama a construir una lista completa, justo lo que la lección anterior evitaba.
Visualización: qué genera @dataclass
Observa el momento en que el decorador lee las anotaciones y crea los métodos, y cómo el __init__ generado asigna los campos.
from dataclasses import dataclass@dataclassclass Venta: producto: str unidades: int precio: float def total(self) -> float: return self.unidades * self.preciov = Venta("teclado", 3, 120.0)print(v.total())dataclass. Todavía no hay ninguna clase.Sin variables en este paso.
—
Avanza paso a paso: observa la línea resaltada, las variables y la salida.
Errores comunes
| Mensaje o síntoma | Causa | Solución |
|---|---|---|
error: Incompatible types in assignment (expression has type "str", variable has type "int") | mypy ve que asignas un texto a una variable anotada como entero | Convierte con int(...) o corrige la anotación |
TypeError: Venta.__init__() missing 1 required positional argument: 'precio' | Falta un campo obligatorio sin valor por defecto | Pásalo o dale un valor por defecto en la dataclass |
ValueError: mutable default <class 'list'> for field etiquetas is not allowed | Usaste = [] como valor por defecto | Usa field(default_factory=list) |
FrozenInstanceError: cannot assign to field 'x' | La dataclass es frozen=True | Crea una instancia nueva con dataclasses.replace |
TypeError: 'type' object is not subscriptable con list[int] | Estás en Python 3.8 o anterior | Importa List de typing o actualiza a 3.9+ |
from dataclasses import dataclass
@dataclass
class Curso:
nombre: str
alumnos: list = [] # ValueError al definir la clasefrom dataclasses import dataclass, field
@dataclass
class Curso:
nombre: str
alumnos: list = field(default_factory=list)Un valor por defecto se evalúa una sola vez, al definir la clase. Si fuera una lista, todas las instancias compartirían la misma y `a.alumnos.append(...)` modificaría también `b.alumnos`. `default_factory` llama a la función en cada instancia y evita el problema; la dataclass lo exige para tipos mutables.
Recuerda el otro error silencioso: creer que la anotación valida. Venta("teclado", "3", 120.0) se crea sin protestar y falla más tarde en total. Si el dato viene de fuera, la conversión y la validación son tu responsabilidad, no la del intérprete.
Buenas prácticas: cuándo tipar y cuánto
- Anota las fronteras: parámetros y valores de retorno de las funciones públicas.
- No anotes lo obvio dentro de funciones cortas; el ruido también cansa.
- Prefiere
list[float]ydict[str, int]en Python 3.9+;X | Nonesolo desde 3.10. - Usa
Optionalcuando elNonesea parte del contrato, y compruébalo antes de operar. - Ejecuta
mypy modulo.pyen CI para que los tipos no se degraden con el tiempo. - En dataclasses,
frozen=Truepara valores que no deben cambiar yfieldpara mutables. - Valida reglas de negocio en
__post_init__; el tipado no sustituye a la validación.
Ejercicios
Producto inmutable y validado
Producto con nombre: str, precio: float y stock: int. Debe ser inmutable, rechazar precios negativos con ValueError, ofrecer una propiedad disponible que devuelva True si hay stock, y un método con_stock(n) que devuelva una copia con el stock actualizado. Prueba los tres casos.Pista
frozen=True impide asignar campos, así que para la copia usa dataclasses.replace(self, stock=n). La validación va en __post_init__.
Tipar un resumen de notas
promedio(notas: list[float]) -> float que devuelva 0.0 si la lista está vacía y el promedio redondeado a dos decimales en caso contrario. Añade una dataclass Estudiante con nombre y notas, y una función mejor(estudiantes) que devuelva el nombre del de mayor promedio o None si la lista está vacía.Pista
Anota el retorno de mejor como Optional[str]. Para no dividir entre cero, comprueba if not notas al principio de promedio.
from dataclasses import dataclass, field @dataclass class Curso: nombre: str notas: [float] = field(default_factory=list) def promedio(self) -> return round(sum(self.notas) / len(self.notas), 2)
El contenedor de una secuencia de decimales es `list`. El promedio de varios `float` es otro `float`.
Soluciones explicadas
En Producto, la combinación de frozen=True y replace es la forma idiomática de tener un objeto inmutable con operaciones que producen versiones nuevas. Si hubieras intentado self.stock = n dentro de con_stock, la dataclass habría lanzado FrozenInstanceError en tiempo de ejecución; mypy, además, lo habría marcado como asignación a un campo de solo lectura.
En notas, la decisión de devolver 0.0 en lugar de lanzar una excepción es una elección de diseño, no una verdad universal. Si el promedio de una lista vacía debe ser un error, cambia la firma a float y lanza ValueError; lo importante es que la firma y el comportamiento cuenten la misma historia. Una función que a veces devuelve None y a veces un número debe decir Optional.
Fíjate en el detalle de "Producto" entre comillas: las anotaciones se evalúan al definir la clase, cuando el nombre todavía no existe en el ámbito del módulo. Las comillas la convierten en una cadena que la herramienta de tipos resuelve después. Otra opción es añadir from __future__ import annotations al principio del archivo, que aplaza todas las anotaciones.
Resumen
- Las anotaciones se guardan en
__annotations__y no se comprueban al ejecutar. mypyanaliza el código sin ejecutarlo y avisa de incompatibilidades antes de producción.Optional[X]esXoNone; no significa «parámetro con valor por defecto».- Desde Python 3.9 puedes escribir
list[float]ydict[str, int];X | Nonellega en 3.10. @dataclassgenera__init__,__repr__y__eq__a partir de las anotaciones.- Usa
field(default_factory=...)para valores mutables y__post_init__para validar. frozen=Trueda inmutabilidad y hash;order=Truegenera las comparaciones.
Evaluación
¿Qué ocurre en tiempo de ejecución con def f(x: int) -> str: si llamas f("hola")?
¿Qué significa Optional[float] en una anotación de retorno?
¿Por qué alumnos: list = [] falla dentro de una dataclass?
¿Ya lo entendiste?
Márcala como completada para llevar tu progreso y desbloquear el siguiente paso de la ruta.