Python ejecuta tu código con tipado dinámico: una variable puede ser un int ahora y un str en la siguiente línea, y el intérprete no se queja hasta que el error ocurre en tiempo de ejecución. Los type hints —introducidos formalmente en PEP 484 con Python 3.5— son anotaciones sintácticas que describen qué tipos espera y devuelve una función, sin cambiar absolutamente nada en cómo Python ejecuta ese código.
El intérprete lee esas anotaciones, las almacena en el atributo __annotations__ del objeto, y las olvida. No valida nada. Puedes escribir def foo(x: int) -> str: y llamarla con una lista; Python no protestará. Quien sí protesta —y esa es toda la gracia— son herramientas externas como mypy, pyright o los analizadores estáticos integrados en VS Code y PyCharm.
Entonces, ¿por qué existe este sistema si el lenguaje no lo enforcea? Porque el problema que resuelve no es de runtime, es de comprensión. En una codebase mediana, cuando ves process_order(order, config, dry_run), no tienes idea de qué es order sin leer toda la implementación. ¿Es un dict? ¿Una instancia de Order? ¿Acepta None? Con tipado gradual —la capacidad de añadir anotaciones incrementalmente, archivo por archivo, función por función— puedes ir respondiendo esas preguntas sin reescribir nada.
Lo que se rompe si ignoras esto en proyectos grandes: refactorizaciones ciegas, bugs silenciosos donde None viaja tres capas de profundidad antes de explotar, y la necesidad de leer implementaciones completas para entender contratos entre módulos.
from __future__ import annotations # permite forward references en Python < 3.10
from dataclasses import dataclass
from typing import Optional
@dataclass
class Item:
name: str
price: float
quantity: int
@dataclass
class Order:
items: list[Item]
customer_id: str
discount: Optional[float] = None # None significa "sin descuento aplicado"
def calculate_total(order: Order, tax_rate: float = 0.21) -> float:
subtotal = sum(item.price * item.quantity for item in order.items)
if order.discount is not None:
subtotal *= 1 - order.discount # discount es un factor, ej: 0.10 = 10%
return subtotal * (1 + tax_rate)
def find_item(order: Order, name: str) -> Optional[Item]:
# Devuelve None si no existe: el tipo lo comunica sin comentarios extra
return next((item for item in order.items if item.name == name), None)
def apply_discount(order: Order, discount: float) -> Order:
# Devuelve una nueva instancia; no muta el original
return Order(
items=order.items,
customer_id=order.customer_id,
discount=discount,
)
# Esto es perfectamente legal en runtime, aunque mypy lo rechazaría:
# calculate_total("esto no es un Order") # ← mypy: error, str no es Order
Qué nos dice cada decisión de tipos
Optional[float] en el campo discount hace explícito algo que de otro modo estaría escondido en la lógica: este valor puede no existir, y cualquier código que lo use debe manejarlo. Sin la anotación, un desarrollador nuevo podría asumir que siempre llega un float y olvidar el caso None. Mypy se asegura de que no puedas hacer 1 - order.discount sin antes verificar que no es None.
list[Item] en items —sintaxis disponible directamente desde Python 3.9; antes necesitabas List[Item] de typing— le dice a las herramientas qué esperar de cada elemento del iterable. El autocompletado del IDE puede entonces ofrecerte item.price dentro del sum, porque sabe que item es un Item.
La firma find_item(...) -> Optional[Item] es un contrato documentado en el código mismo: esta función puede no encontrar nada, y ese hecho es parte de su interfaz pública, no un detalle de implementación. Cualquier llamador que ignore el posible None y acceda directamente a .name recibirá una advertencia de mypy antes de que el bug llegue a producción.
apply_discount devuelve Order en lugar de mutar. Esto no lo fuerza el sistema de tipos, pero las anotaciones hacen evidente el patrón: el tipo de retorno confirma que hay algo nuevo saliendo de la función, lo que guía la expectativa del lector.
El import from __future__ import annotations activa la evaluación diferida de todas las anotaciones del módulo —las convierte en strings en lugar de evaluarlas inmediatamente— lo que permite referencias circulares entre clases definidas en el mismo archivo y mejora el tiempo de arranque en módulos con muchas anotaciones.
Errores que debes conocer
Error: usar Optional[X] pero olvidar verificar None antes de operar sobre el valor, confiando en que “en la práctica siempre llega algo”.
# ❌ Wrong
def get_discount_label(order: Order) -> str:
return f"Descuento: {order.discount * 100:.1f}%" # TypeError si discount es None
# ✅ Right
def get_discount_label(order: Order) -> str:
if order.discount is None:
return "Sin descuento"
return f"Descuento: {order.discount * 100:.1f}%"
El guard explícito elimina el riesgo de TypeError en runtime y satisface al verificador de tipos, que requiere que los dos casos de Optional estén cubiertos.
Error: anotar el tipo de retorno como el tipo base cuando la función puede devolver None, lo que engaña a mypy y al lector.
# ❌ Wrong
def find_item(order: Order, name: str) -> Item: # miente: puede devolver None
return next((item for item in order.items if item.name == name), None)
# ✅ Right
def find_item(order: Order, name: str) -> Optional[Item]:
return next((item for item in order.items if item.name == name), None)
Mentirle a mypy es peor que no anotar: genera una falsa seguridad que puede hacer que los llamadores asuman que el resultado nunca es None.
N° 125