Tipado en Python: Estructuras de datos
Tabla de Contenido
Cuando una función recibe datos de un producto, un usuario o una respuesta de una API, el problema no suele ser solamente el tipo de cada valor, sino la forma completa del objeto. Las estructuras de datos tipadas en Python nos permiten documentar y validar mejor esa forma: qué claves existen, cuáles son opcionales y cuándo conviene pasar de un diccionario a una estructura con comportamiento propio.
Note
Este artículo es parte de una serie que explica el tipado en Python. A continuación, puedes ver la recopilación de todos los posts de la serie.
En artículos anteriores vimos cómo agregar tipos a variables, colecciones y funciones. Ahora vamos a llevar esa idea a estructuras que representan datos con significado dentro de una aplicación: productos, órdenes, usuarios, configuraciones o payloads de entrada/salida.
Diccionarios tipados en Python
Los diccionarios son una de las formas más comunes de representar datos en Python. Nos permiten asociar llaves con valores, y esas llaves deben ser objetos hashables, normalmente str, int o tuplas inmutables.
Para agregar tipado a un diccionario podemos usar dict[<tipo_llave>, <tipo_valor>]:
product: dict[str, str | int] = {
"name": "Camisa",
"price": 20,
}
En este ejemplo estamos indicando que las llaves son str y que los valores pueden ser str o int. Esto ayuda a evitar errores como usar una llave de tipo incorrecto o asignar un valor fuera del rango esperado.
Sin embargo, esta aproximación tiene una limitación importante: no transmite qué claves se esperan dentro del diccionario. El analizador estático sabe que puede existir cualquier llave str, pero no sabe que esperamos exactamente name y price.
product: dict[str, str | int] = {
"name": "Camisa",
"price": 20,
}
product["name"] # ✅ válido
product["category"] # ✅ también parece válido para el type checker
El segundo acceso puede fallar en tiempo de ejecución con un KeyError, pero el tipo dict[str, str | int] no expresa que category no hace parte del contrato de esa estructura.
Info
Un diccionario tipado con dict[str, ...] es útil cuando trabajas con pares clave-valor homogéneos o dinámicos. Cuando necesitas describir una forma específica de datos, conviene usar TypedDict.
TypedDict: diccionarios con forma definida
TypedDict, disponible desde el módulo typing, nos permite definir un diccionario con claves conocidas y valores tipados. Es especialmente útil para payloads JSON, respuestas de APIs o datos que siguen siendo diccionarios, pero tienen una estructura clara.
from typing import TypedDict
class Product(TypedDict):
name: str
price: int
tags: list[str]
active: bool
product: Product = {
"name": "Camisa",
"price": 20,
"tags": ["ropa"],
"active": True,
}
Con esta definición, el type checker entiende que Product debe tener las claves name, price, tags y active, y que cada una espera un tipo específico.
Es importante recordar que TypedDict no cambia el comportamiento del diccionario en tiempo de ejecución. Seguimos trabajando con un dict, no con una clase tradicional.
product["name"] # ✅ funciona
product.name # ❌ no funciona
TypedDict mejora la información disponible para herramientas como editores, linters y analizadores estáticos. Pero el acceso sigue siendo por llave, igual que en cualquier diccionario.
Warning
No uses TypedDict esperando validación automática en tiempo de ejecución. Su objetivo principal es mejorar el tipado estático y la documentación del contrato de datos.
Llaves opcionales con NotRequired
En muchos casos una estructura tiene campos obligatorios y campos opcionales. Por ejemplo, un producto puede requerir name, price y active, pero permitir que tags no exista.
Para ese caso podemos usar NotRequired:
from typing import NotRequired, TypedDict
class Product(TypedDict):
name: str
price: int
tags: NotRequired[list[str]]
active: bool
product_without_tags: Product = {
"name": "Camisa",
"price": 20,
"active": True,
}
La clave tags ahora puede omitirse. Esto es diferente a decir que tags puede ser None. Con NotRequired[list[str]], la llave completa puede no existir.
class Product(TypedDict):
name: str
tags: NotRequired[list[str]]
product: Product = {"name": "Camisa"}
product.get("tags") # ✅ acceso seguro si la llave puede no existir
product["tags"] # ⚠️ puede lanzar KeyError en tiempo de ejecución
Tip
Si una llave es opcional, usa get() o valida su existencia antes de acceder con []. El tipado te ayuda a diseñar el contrato, pero no elimina los errores de acceso en tiempo de ejecución.
Enfoque inverso: total=False y Required
También podemos definir el caso contrario: que todas las llaves sean opcionales por defecto y marcar únicamente algunas como obligatorias. Para ello usamos total=False junto con Required.
from typing import Required, TypedDict
class Product(TypedDict, total=False):
name: Required[str]
price: Required[int]
tags: list[str]
active: bool
product: Product = {
"name": "Camisa",
"price": 20,
}
Aquí name y price son obligatorias, mientras que tags y active pueden omitirse. Este enfoque es útil cuando la mayoría de campos son opcionales y solo necesitas destacar unos pocos como requeridos.
La decisión entre NotRequired y total=False + Required depende de la forma dominante de tu estructura:
- Usa
NotRequiredcuando la mayoría de campos son obligatorios. - Usa
total=False + Requiredcuando la mayoría de campos son opcionales.
Dataclass: estructuras de datos con atributos
Una dataclass es una clase diseñada para almacenar datos con menos código repetitivo. Python agregó este decorador en la versión 3.7, y desde entonces se convirtió en una herramienta muy práctica para modelar objetos de datos.
from dataclasses import dataclass
@dataclass
class Product:
name: str
price: int
active: bool
tags: list[str]
product = Product(name="Camisa", price=20, active=True, tags=["ropa"])
print(product.name)
print(product.price)
A diferencia de TypedDict, aquí sí accedemos a los datos como atributos: product.name, product.price, product.tags. Además, dataclass genera automáticamente métodos como __init__, __repr__ y __eq__, lo que reduce la cantidad de código necesario.
product_1 = Product(name="Camisa", price=20, active=True, tags=["ropa"])
product_2 = Product(name="Camisa", price=20, active=True, tags=["ropa"])
print(product_1 == product_2) # True
print(product_1) # Product(name='Camisa', price=20, active=True, tags=['ropa'])
Info
dataclass sigue siendo una clase de Python. El decorador solo genera código común por ti, sin impedir que agregues métodos, validaciones o comportamiento adicional cuando lo necesites.
Dataclass vs class tradicional
Podemos escribir una clase tradicional para representar el mismo producto:
class Product:
def __init__(
self,
name: str,
price: int,
active: bool,
tags: list[str],
) -> None:
self.name = name
self.price = price
self.active = active
self.tags = tags
El código funciona, pero gran parte de la implementación es repetitiva. Solo estamos recibiendo argumentos y asignándolos como atributos. Con dataclass, expresamos la misma estructura de forma más directa:
from dataclasses import dataclass
@dataclass
class Product:
name: str
price: int
active: bool
tags: list[str]
La diferencia no es solamente estética. dataclass comunica intención: esta clase existe principalmente para alojar datos. Además, mantiene una buena integración con type hints y reduce el riesgo de inconsistencias entre los argumentos de __init__ y los atributos reales.
También puedes agregar comportamiento cuando haga falta:
from dataclasses import dataclass
@dataclass
class Product:
name: str
price: int
active: bool
tags: list[str]
def has_tag(self, tag: str) -> bool:
return tag in self.tags
product = Product(name="Camisa", price=20, active=True, tags=["ropa"])
print(product.has_tag("ropa")) # True
Tip
Si tu clase solo define __init__, __repr__ o comparaciones básicas para alojar datos, probablemente una dataclass expresa mejor la intención.
Cuándo usar cada estructura de datos
No existe una única estructura correcta para todos los casos. La decisión depende de si estás modelando datos dinámicos, contratos de intercambio o entidades con comportamiento.
flowchart TD
A[Necesitas representar datos con estructura] --> B{¿Debe seguir siendo un dict?}
B -- Sí --> C{¿Conoces las claves esperadas?}
C -- No --> D["Usa dict[str, tipo]"]
C -- Sí --> E[Usa TypedDict]
B -- No --> F{¿Principalmente almacena datos?}
F -- Sí --> G[Usa dataclass]
F -- No --> H[Usa class tradicional]
Como regla práctica:
dict[str, tipo]: úsalo cuando las claves son dinámicas o la estructura es homogénea. Por ejemplo, contadores, mapas de configuración simples o agrupaciones donde todas las llaves comparten el mismo tipo de valor.TypedDict: úsalo cuando necesitas conservar el comportamiento de diccionario, pero quieres describir claves específicas. Es una buena opción para JSON, payloads de APIs y datos serializables.dataclass: úsala cuando quieres representar datos con atributos, comparación automática y una intención clara de modelo de datos. Funciona muy bien para capas internas de dominio o estructuras intermedias.classtradicional: úsala cuando el objeto necesita controlar invariantes complejas, encapsular estado mutable o exponer comportamiento más importante que los datos que almacena.
La frontera entre dataclass y class tradicional no es rígida. Una dataclass puede tener métodos, y una clase tradicional puede almacenar datos. La clave está en comunicar intención y evitar código accidentalmente complejo.
Warning
No conviertas todo diccionario en dataclass ni todo objeto en TypedDict. Elige la estructura según el contrato que quieres expresar y el comportamiento que necesitas en el sistema.
Cierre
Las estructuras de datos tipadas en Python nos ayudan a hacer explícitos los contratos que antes quedaban implícitos en diccionarios o clases repetitivas. dict[str, ...] funciona para casos homogéneos, TypedDict describe diccionarios con forma específica, NotRequired y Required permiten modelar campos opcionales, y dataclass reduce el código necesario para representar datos con atributos.
Lo importante es usar el tipado como una herramienta de diseño. Cuando la estructura comunica intención, el código se vuelve más fácil de leer, refactorizar y validar antes de llegar a producción.
Note
Esta es la pieza final de la serie hasta el momento. Puedes volver a la recopilación completa para repasar los conceptos anteriores sobre tipado en Python.
