17. Usar dataclasses y type hints para aclarar estructuras de datos

17.1 Objetivo del tema

Los diccionarios son útiles, pero cuando representan entidades importantes pueden volverse ambiguos. No sabemos qué claves son obligatorias, qué tipo tiene cada valor ni qué estructura se espera sin leer todo el código.

En este tema usaremos dataclasses y type hints para aclarar estructuras de datos. Refactorizaremos código que usa diccionarios hacia modelos más explícitos, manteniendo pruebas y verificaciones con mypy.

Objetivo práctico: reemplazar datos ambiguos por estructuras tipadas que expresen intención y reduzcan errores.

17.2 Por qué agregar tipos durante un refactoring

Los tipos ayudan a documentar qué espera una función y qué devuelve. No reemplazan las pruebas, pero detectan errores antes de ejecutar el programa y hacen más claro el contrato entre partes del código.

En Python, los type hints son graduales: podemos agregarlos poco a poco. No hace falta tipar todo el proyecto en un solo paso.

17.3 Código inicial con diccionarios

Crea el archivo src/catalogo.py:

def calcular_precio_final(producto):
    precio = producto["precio"]

    if producto["categoria"] == "software":
        precio = precio * 0.9

    if producto["stock"] == 0:
        precio = precio * 0.8

    return round(precio, 2)


def crear_etiqueta(producto):
    return producto["codigo"] + " - " + producto["nombre"]

El código espera claves específicas, pero la estructura de producto no está declarada en ningún lugar.

17.4 Pruebas antes de cambiar estructura

Crea tests/test_catalogo.py:

from catalogo import calcular_precio_final, crear_etiqueta


def test_calcula_precio_final_de_software_con_stock():
    producto = {
        "codigo": "SW-1",
        "nombre": "Editor Pro",
        "categoria": "software",
        "precio": 10000,
        "stock": 5,
    }

    assert calcular_precio_final(producto) == 9000.0


def test_crea_etiqueta():
    producto = {
        "codigo": "BK-1",
        "nombre": "Python Básico",
        "categoria": "libro",
        "precio": 5000,
        "stock": 3,
    }

    assert crear_etiqueta(producto) == "BK-1 - Python Básico"

Ejecuta:

python -m pytest tests/test_catalogo.py

17.5 Crear una dataclass

Ahora declaramos la estructura de Producto:

from dataclasses import dataclass


@dataclass
class Producto:
    codigo: str
    nombre: str
    categoria: str
    precio: float
    stock: int

La clase deja claro qué datos componen un producto. Además, Python genera automáticamente el constructor y una representación útil para depuración.

17.6 Crear funciones nuevas tipadas

Para migrar de forma gradual, creamos funciones nuevas que reciben Producto:

def calcular_precio_final_v2(producto: Producto) -> float:
    precio = producto.precio

    if producto.categoria == "software":
        precio = precio * 0.9

    if producto.stock == 0:
        precio = precio * 0.8

    return round(precio, 2)


def crear_etiqueta_v2(producto: Producto) -> str:
    return producto.codigo + " - " + producto.nombre

Los accesos por atributo son más explícitos que las claves de diccionario y los tipos documentan el contrato.

17.7 Probar la nueva estructura

Agrega pruebas usando la dataclass:

from catalogo import Producto, calcular_precio_final, calcular_precio_final_v2, crear_etiqueta


def test_calcula_precio_final_v2_de_software_con_stock():
    producto = Producto(
        codigo="SW-1",
        nombre="Editor Pro",
        categoria="software",
        precio=10000,
        stock=5,
    )

    assert calcular_precio_final_v2(producto) == 9000.0

Ejecuta python -m pytest tests/test_catalogo.py. Las funciones viejas y nuevas pueden convivir durante la migración.

17.8 Función adaptadora desde diccionario

Si todavía recibimos diccionarios desde otra parte del sistema, podemos convertirlos:

def producto_desde_dict(datos: dict) -> Producto:
    return Producto(
        codigo=datos["codigo"],
        nombre=datos["nombre"],
        categoria=datos["categoria"],
        precio=datos["precio"],
        stock=datos["stock"],
    )

Luego la función vieja puede delegar:

def calcular_precio_final(producto):
    return calcular_precio_final_v2(producto_desde_dict(producto))

17.9 Usar tipos para listas

Cuando una función recibe una lista de productos, también podemos declararlo:

def calcular_valor_inventario(productos: list[Producto]) -> float:
    total = 0
    for producto in productos:
        total = total + producto.precio * producto.stock
    return round(total, 2)

Esta firma comunica que la lista debe contener objetos Producto, no diccionarios ni strings.

17.10 Ejecutar mypy

Si tienes mypy instalado en el entorno del proyecto, puedes ejecutar:

mypy src

También puedes llamarlo a través de Python si está instalado como módulo:

python -m mypy src

mypy revisa anotaciones de tipo y puede detectar llamadas incompatibles antes de ejecutar las pruebas.

17.11 Valores por defecto en dataclasses

Podemos definir valores por defecto cuando representan el caso común:

@dataclass
class Producto:
    codigo: str
    nombre: str
    categoria: str
    precio: float
    stock: int = 0

Los campos sin valor por defecto deben ir antes que los campos con valor por defecto.

17.12 Cuidado con listas por defecto

En una dataclass, no uses una lista vacía directamente como valor por defecto:

# Evitar
@dataclass
class Pedido:
    items: list = []

Usa field(default_factory=list):

from dataclasses import dataclass, field


@dataclass
class Pedido:
    items: list[Producto] = field(default_factory=list)

Así cada instancia recibe su propia lista.

17.13 Inmutabilidad con frozen

Si un objeto representa un valor que no debería cambiar, podemos usar frozen=True:

@dataclass(frozen=True)
class CodigoProducto:
    valor: str

Esto ayuda a evitar modificaciones accidentales. Es especialmente útil en objetos de valor como códigos, fechas, porcentajes o dinero.

17.14 No tipar todo de golpe

El tipado gradual funciona mejor si se aplica alrededor del código que estamos refactorizando. Una estrategia segura es:

  • Agregar tipos a una función o módulo pequeño.
  • Crear dataclasses para estructuras centrales.
  • Agregar adaptadores desde diccionarios viejos.
  • Ejecutar pruebas y mypy.
  • Migrar llamadas de a poco.

17.15 Ejercicio propuesto

Crea el archivo src/alumnos_modelo.py:

def calcular_promedio(alumno):
    notas = alumno["notas"]
    if not notas:
        return 0
    return round(sum(notas) / len(notas), 2)


def crear_resumen(alumno):
    return alumno["legajo"] + " - " + alumno["nombre"]

Realiza estas tareas:

  • Escribe pruebas para promedio y resumen.
  • Crea una dataclass Alumno con type hints.
  • Crea versiones nuevas que reciban Alumno.
  • Agrega una función adaptadora desde diccionario.
  • Usa field(default_factory=list) si defines una lista por defecto.
  • Ejecuta python -m pytest y luego python -m mypy src.

17.16 Una posible solución

Una solución posible es:

from dataclasses import dataclass, field


@dataclass
class Alumno:
    legajo: str
    nombre: str
    notas: list[float] = field(default_factory=list)


def alumno_desde_dict(datos: dict) -> Alumno:
    return Alumno(
        legajo=datos["legajo"],
        nombre=datos["nombre"],
        notas=datos.get("notas", []),
    )


def calcular_promedio_v2(alumno: Alumno) -> float:
    if not alumno.notas:
        return 0
    return round(sum(alumno.notas) / len(alumno.notas), 2)


def crear_resumen_v2(alumno: Alumno) -> str:
    return alumno.legajo + " - " + alumno.nombre


def calcular_promedio(alumno):
    return calcular_promedio_v2(alumno_desde_dict(alumno))


def crear_resumen(alumno):
    return crear_resumen_v2(alumno_desde_dict(alumno))

Las funciones viejas siguen funcionando con diccionarios, mientras el código nuevo puede usar Alumno.

17.17 Lista de verificación

Antes de continuar, verifica que puedes explicar estos puntos:

  • Qué problema resuelven las dataclasses.
  • Cómo los type hints documentan contratos entre funciones.
  • Cómo migrar desde diccionarios usando una función adaptadora.
  • Cómo tipar una lista de objetos.
  • Por qué se usa field(default_factory=list).
  • Cuándo conviene usar frozen=True.

17.18 Conclusión

En este tema usamos dataclasses y type hints para aclarar estructuras de datos. Pasamos de diccionarios ambiguos a modelos explícitos que comunican mejor qué datos existen y cómo se usan.

En el próximo tema reemplazaremos condicionales de tipo por polimorfismo simple en Python.