AulaML Python · C++
Python intermedio · 38 min · Python avanzado

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.

Lección 3 de 6 del módulo
Al terminar esta lección podrás
  • 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
  1. ¿Qué es una anotación de tipo?
  2. ¿Para qué sirve tipar?
  3. ¿Cómo funciona por dentro?
  4. Sintaxis: variables, funciones y contenedores
  5. Ejemplo básico: anotar una función
  6. Ejemplo intermedio: cuando falta un dato
  7. Dataclasses: datos con estructura
  8. Caso práctico: tipar un pipeline de datos
  9. Visualización: qué genera @dataclass
  10. Errores comunes
  11. Buenas prácticas: cuándo tipar y cuánto
  12. Ejercicios
  13. Soluciones explicadas
  14. Resumen
  15. 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, None inesperados y retornos incoherentes.
  • Autocompletado real: si el editor sabe que venta es una Venta, 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

PYTHON anotaciones.py
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ónSignificaEjemplo de valor válido
intNúmero entero3
Optional[float]Un float o NoneNone, 2.5
Union[int, str]Entero o texto7, "A-12"
list[float]Lista de decimales[1.0, 2.5]
dict[str, int]Diccionario texto → entero{"a": 1}
AnyCualquier cosa; desactiva la comprobaciónNone, 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.

PYTHON igv.py
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.

PYTHON buscar.py
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.

PYTHON venta.py
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.

PYTHON punto.py
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:

PYTHON rango.py
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.

PYTHON pipeline_tipado.py
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.

Ejecución visual ·De anotaciones a objeto
1 / 5
1 from dataclasses import dataclass
2
3 @dataclass
4 class Venta:
5 producto: str
6 unidades: int
7 precio: float
8
9 def total(self) -> float:
10 return self.unidades * self.precio
11
12 v = Venta("teclado", 3, 120.0)
13 print(v.total())
Se importa el decorador dataclass. Todavía no hay ninguna clase.
Variables

Sin variables en este paso.

Salida
—

Avanza paso a paso: observa la línea resaltada, las variables y la salida.

Errores comunes

Mensaje o síntomaCausaSolució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 enteroConvierte con int(...) o corrige la anotación
TypeError: Venta.__init__() missing 1 required positional argument: 'precio'Falta un campo obligatorio sin valor por defectoPásalo o dale un valor por defecto en la dataclass
ValueError: mutable default <class 'list'> for field etiquetas is not allowedUsaste = [] como valor por defectoUsa field(default_factory=list)
FrozenInstanceError: cannot assign to field 'x'La dataclass es frozen=TrueCrea una instancia nueva con dataclasses.replace
TypeError: 'type' object is not subscriptable con list[int]Estás en Python 3.8 o anteriorImporta List de typing o actualiza a 3.9+
El valor por defecto que se comparte entre objetos
Incorrecto
PYTHON Una sola lista para todos
from dataclasses import dataclass

@dataclass
class Curso:
    nombre: str
    alumnos: list = []   # ValueError al definir la clase
Correcto
PYTHON Una lista por instancia
from 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] y dict[str, int] en Python 3.9+; X | None solo desde 3.10.
  • Usa Optional cuando el None sea parte del contrato, y compruébalo antes de operar.
  • Ejecuta mypy modulo.py en CI para que los tipos no se degraden con el tiempo.
  • En dataclasses, frozen=True para valores que no deben cambiar y field para mutables.
  • Valida reglas de negocio en __post_init__; el tipado no sustituye a la validación.

Ejercicios

Ejercicio 1

Producto inmutable y validado

Crea una dataclass 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__.

Ejercicio 2

Tipar un resumen de notas

Escribe 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.

Completar código Completa la dataclass
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.
  • mypy analiza el código sin ejecutarlo y avisa de incompatibilidades antes de producción.
  • Optional[X] es X o None; no significa «parámetro con valor por defecto».
  • Desde Python 3.9 puedes escribir list[float] y dict[str, int]; X | None llega en 3.10.
  • @dataclass genera __init__, __repr__ y __eq__ a partir de las anotaciones.
  • Usa field(default_factory=...) para valores mutables y __post_init__ para validar.
  • frozen=True da inmutabilidad y hash; order=True genera las comparaciones.

Evaluación

Evaluación 1

¿Qué ocurre en tiempo de ejecución con def f(x: int) -> str: si llamas f("hola")?

Evaluación 2

¿Qué significa Optional[float] en una anotación de retorno?

Evaluación 3

¿Por qué alumnos: list = [] falla dentro de una dataclass?

Tus notas

¿Ya lo entendiste?

Márcala como completada para llevar tu progreso y desbloquear el siguiente paso de la ruta.