Type hints y dataclasses en Python: código más legible y seguro
Los type hints (anotaciones de tipo) de Python no son obligatorios en tiempo de ejecución, pero mejoran enormemente la legibilidad, la detección de errores y la experiencia en el IDE. Los dataclasses reducen el boilerplate al definir clases que principalmente almacenan datos.
1. Anotaciones de tipo básicas
# Sin anotaciones
def calcular_precio(cantidad, precio_unitario, descuento):
return cantidad * precio_unitario * (1 - descuento)
# Con anotaciones
def calcular_precio(
cantidad: int,
precio_unitario: float,
descuento: float = 0.0
) -> float:
return cantidad * precio_unitario * (1 - descuento)
# Variables
nombre: str = "Ana"
edad: int = 28
activo: bool = True
precio: float = 19.99
# Tipos de colecciones (Python 3.9+)
nombres: list[str] = ["Ana", "Bob"]
coordenadas: tuple[float, float] = (40.416, -3.703)
config: dict[str, int] = {"timeout": 30, "retries": 3}
etiquetas: set[str] = {"python", "web"}
2. Optional, Union y tipos compuestos
from typing import Optional, Union
# Optional[X] equivale a X | None
def buscar_usuario(id: int) -> Optional[dict]:
# Puede devolver un dict o None
return None
# Python 3.10+ puedes usar X | None directamente
def buscar_v2(id: int) -> dict | None:
return None
# Union: acepta múltiples tipos
def formatear(valor: Union[int, float, str]) -> str:
return str(valor)
# Python 3.10+
def formatear_v2(valor: int | float | str) -> str:
return str(valor)
# Any: desactiva el chequeo de tipos
from typing import Any
def log(datos: Any) -> None:
print(datos)
3. Tipos especiales: Callable, Sequence, Mapping
from typing import Callable, Sequence, Mapping, Iterable, Iterator
# Callable[[tipos_params], tipo_retorno]
def aplicar(func: Callable[[int, int], int], a: int, b: int) -> int:
return func(a, b)
resultado = aplicar(lambda x, y: x + y, 3, 4)
# Sequence: cualquier secuencia (lista, tupla, str...)
def contar(items: Sequence[str]) -> int:
return len(items)
# Mapping: cualquier diccionario
def obtener_valor(data: Mapping[str, int], clave: str) -> int:
return data.get(clave, 0)
# Iterable e Iterator
def primeros_n(it: Iterable[int], n: int) -> list[int]:
resultado = []
for i, x in enumerate(it):
if i >= n:
break
resultado.append(x)
return resultado
4. TypeVar y genéricos
from typing import TypeVar, Generic
T = TypeVar("T")
def primero(lista: list[T]) -> T:
return lista[0]
print(primero([1, 2, 3])) # int
print(primero(["a", "b"])) # str
# Clase genérica
class Pila(Generic[T]):
def __init__(self) -> None:
self._items: list[T] = []
def apilar(self, item: T) -> None:
self._items.append(item)
def desapilar(self) -> T:
return self._items.pop()
def __len__(self) -> int:
return len(self._items)
pila: Pila[int] = Pila()
pila.apilar(1)
pila.apilar(2)
print(pila.desapilar()) # 2
5. dataclasses: clases de datos sin boilerplate
from dataclasses import dataclass, field
from datetime import date
@dataclass
class Producto:
nombre: str
precio: float
stock: int = 0
activo: bool = True
def precio_con_iva(self, tasa: float = 0.21) -> float:
return self.precio * (1 + tasa)
def __str__(self) -> str:
return f"{self.nombre} ({self.precio:.2f}€)"
p = Producto("Teclado", 49.99, stock=100)
print(p) # Teclado (49.99€)
print(p.precio_con_iva()) # 60.4879...
print(p == Producto("Teclado", 49.99, 100)) # True (comparación automática)
Campos con valores por defecto complejos
from dataclasses import dataclass, field
from typing import List
@dataclass
class Pedido:
id: int
cliente: str
# NUNCA: items: list = [] — error mutable default
items: list[str] = field(default_factory=list)
descuento: float = field(default=0.0)
etiquetas: set[str] = field(default_factory=set)
# Campo que no aparece en __init__ ni __repr__
_total_cache: float = field(default=0.0, init=False, repr=False)
pedido = Pedido(id=1, cliente="Ana")
pedido.items.append("Teclado")
pedido.items.append("Ratón")
print(pedido)
# Pedido(id=1, cliente='Ana', items=['Teclado', 'Ratón'], descuento=0.0, etiquetas=set())
6. dataclass con frozen, order y slots
from dataclasses import dataclass
# frozen=True: inmutable (como namedtuple pero con más funciones)
@dataclass(frozen=True)
class Punto:
x: float
y: float
def distancia_al_origen(self) -> float:
return (self.x**2 + self.y**2) ** 0.5
p = Punto(3.0, 4.0)
print(p.distancia_al_origen()) # 5.0
# p.x = 10 → FrozenInstanceError
# order=True: habilita <, <=, >, >=
@dataclass(order=True)
class Temperatura:
valor: float
unidad: str = "C"
temps = [Temperatura(30), Temperatura(25), Temperatura(35)]
print(sorted(temps)) # Ordenadas por valor
# slots=True (Python 3.10+): más eficiente en memoria
@dataclass(slots=True)
class Sensor:
id: int
valor: float
7. post_init: validación tras la creación
from dataclasses import dataclass, field
@dataclass
class Rango:
minimo: float
maximo: float
def __post_init__(self):
if self.minimo > self.maximo:
raise ValueError(
f"minimo ({self.minimo}) no puede ser mayor que maximo ({self.maximo})"
)
self.amplitud = self.maximo - self.minimo # Campo calculado
r = Rango(0.0, 100.0)
print(r.amplitud) # 100.0
try:
Rango(50.0, 10.0) # ValueError
except ValueError as e:
print(e)
8. TypedDict para diccionarios tipados
from typing import TypedDict, Required, NotRequired
class ConfigBD(TypedDict):
host: str
puerto: int
nombre: str
usuario: str
password: str
class ConfigApp(TypedDict):
base_datos: ConfigBD
debug: bool
puerto: NotRequired[int] # Campo opcional (Python 3.11+)
config: ConfigApp = {
"base_datos": {
"host": "localhost",
"puerto": 5432,
"nombre": "midb",
"usuario": "admin",
"password": "secreto",
},
"debug": False,
}
9. Verificar tipos con mypy
pip install mypy
mypy mi_modulo.py --strict
# Este código tiene un error de tipo que mypy detectará
def saludar(nombre: str) -> str:
return "Hola, " + nombre
resultado = saludar(42) # mypy: error: Argument 1 to "saludar" has incompatible type "int"; expected "str"
# Configuración en mypy.ini
[mypy]
strict = True
ignore_missing_imports = True
10. Buenas prácticas
- Anota funciones públicas al menos con tipos de parámetros y retorno.
- Usa
Optional[X]/X | Nonesiempre que una función pueda devolver None. - No anotes todo: las variables locales obvias no necesitan anotación.
dataclassvsnamedtuple: dataclass es mutable por defecto y soporta métodos; namedtuple es inmutable y hashable.dataclass(frozen=True)cuando necesites inmutabilidad y hashabilidad.- Evita
Any: es una salida de emergencia, no un tipo. - Ejecuta mypy en CI/CD para detectar errores de tipo automáticamente.
Resumen comparativo
| Característica | class normal |
dataclass |
namedtuple |
TypedDict |
|---|---|---|---|---|
__init__ auto |
No | Sí | Sí | N/A |
__repr__ auto |
No | Sí | Sí | N/A |
__eq__ auto |
No | Sí | Sí | N/A |
| Mutable | Sí | Sí | No | N/A |
| Hashable | No* | No* | Sí | N/A |
| Métodos | Sí | Sí | Limitado | No |
| Tipado | Manual | Auto | Manual | Auto |
Conversiones relacionadas
Conversiones frecuentes del catálogo: