Anotaciones de tipo en Python: escribe código más robusto y mantenible
Artículo completo y código de ejemplo disponible en: python-typing-demo
Python es un lenguaje de programación dinámico y expresivo, ampliamente adoptado en ciencia de datos, desarrollo web, automatización, inteligencia artificial y muchos otros campos. Sin embargo, esta flexibilidad puede conducir a errores que solo se detectan en tiempo de ejecución. Afortunadamente, desde Python 3.5, se introdujo un sistema opcional de anotaciones de tipos estáticos, que ha evolucionado significativamente y se ha convertido en una herramienta fundamental para escribir código más claro y confiable.
Este artículo explora el sistema de tipos de Python a través de un ejemplo práctico: construiremos una aplicación sencilla de gestión de tareas (todo app) mientras cubrimos:
- Qué son las anotaciones de tipo y sus beneficios
- Sintaxis básica para variables, funciones y clases
- Evolución del sistema de tipos a través de las versiones de Python
- Tipos comunes y avanzados del módulo
typing
- Herramientas para validación estática (
mypy
,pyright
) - Herramientas para validación en tiempo de ejecución (
typeguard
,beartype
,pydantic
) - Estrategias para migración progresiva y uso de stubs (
.pyi
) - Mejora de la experiencia en el editor (VSCode, Cursor)
- Errores comunes y buenas prácticas
- Cambios recientes (Python 3.12+) y el futuro del typing
1. ¿Qué son las anotaciones de tipo y por qué usarlas?
Python es dinámicamente tipado, lo que significa que los tipos se verifican en tiempo de ejecución.
# Python infiere los tipos automáticamente
task_id = 1 # tipo inferido: int
title = "Buy groceries" # tipo inferido: str
tags = ["shopping", "important"] # tipo inferido: list[str]
Esto puede llevar a errores de tipo que solo se manifiestan durante la ejecución:
title = "Buy groceries"
len(title) # 18
# Reasignación a un tipo diferente
title = 123
len(title) # TypeError: object of type 'int' has no len()
Las anotaciones de tipo (introducidas en PEP 484) son una forma opcional de añadir información sobre los tipos esperados, ayudando a detectar estos errores antes.
Beneficios:
- Legibilidad y mantenimiento: El código se vuelve autodocumentado
- Prevención de errores: Herramientas de análisis estático detectan inconsistencias antes de ejecutar
- Mejores herramientas de desarrollo: Autocompletado preciso, tooltips, refactorizaciones seguras
- Mayor productividad: Menos tiempo depurando errores de tipo
Las anotaciones no cambian la ejecución en tiempo de ejecución por defecto; son para análisis y claridad.
2. Sintaxis básica
Apliquemos anotaciones a los elementos básicos de nuestra app de tareas.
Variables
Se usan dos puntos (:
) seguidos del tipo.
# Con anotaciones
task_id: int = 1
title: str = "Buy groceries"
completed: bool = False
due_date: str | None = "2023-12-31" # Usando sintaxis moderna (Python 3.10+)
metadata: dict[str, str] = {"priority": "high"} # Sintaxis moderna (Python 3.9+)
tags: list[str] = ["shopping", "important"] # Sintaxis moderna (Python 3.9+)
Funciones
Se anotan los tipos de los parámetros y el tipo de retorno después de ->
.
# tasks debe estar definido previamente, p.ej. tasks: list[dict] = []
tasks: list[dict] = []
def create_task(title: str, due_date: str | None = None) -> dict:
task = {
"id": len(tasks) + 1, # Asume un mecanismo simple de ID
"title": title,
"completed": False,
"due_date": due_date
}
tasks.append(task)
return task
Clases
Se pueden anotar los atributos en el cuerpo de la clase o en __init__
.
import random # Necesario para generate_id
def generate_id() -> int: return random.randint(1000, 9999) # Ejemplo simple
class Task:
# Anotaciones de atributos de instancia
id: int
title: str
completed: bool
due_date: str | None # str o None
tags: list[str]
def __init__(self, title: str, due_date: str | None = None) -> None:
self.id = generate_id()
self.title = title
self.completed = False
self.due_date = due_date
self.tags = [] # Inicializa como lista vacía
# Anotación del tipo de retorno (referencia a la propia clase)
def mark_complete(self) -> "Task":
self.completed = True
return self # Permite encadenar métodos
3. Evolución del typing en Python
El sistema de tipos ha mejorado significativamente con cada versión de Python.
Python 3.5 (PEP 484)
- Introduce el módulo
typing
- Tipos genéricos:
List
,Dict
,Tuple
,Optional
,Union
,Any
# Python 3.5
from typing import List, Dict, Tuple, Any, Optional, Union
tasks: List[Dict[str, Any]] = []
task_id: Union[int, str] = "task-1"
search_term: Optional[str] = None # Equivalente a Union[str, None]
Python 3.6
- Soporte para anotaciones de variables locales
- El atributo
__annotations__
almacena las anotaciones
# Python 3.6
from typing import List, Optional
class Task:
id: int
title: str
# ... (otras anotaciones)
def __init__(self, title: str, due_date: Optional[str] = None) -> None:
# ...
pass
# Acceso a anotaciones
# print(Task.__annotations__)
Python 3.7 (PEP 563)
- Evaluación pospuesta de anotaciones con
from __future__ import annotations
- Permite referencias futuras (ej., usar
Task
dentro de la definición deTask
) - Introduce
typing.Final
para constantes
# Python 3.7
from __future__ import annotations
from typing import List, Optional, Final
DEFAULT_PRIORITY: Final[int] = 1
class TaskList:
tasks: List[Task]
parent_list: Optional[TaskList] # Autorreferencia posible gracias a __future__
def __init__(self) -> None:
self.tasks = []
self.parent_list = None
Python 3.8 (PEP 586, 589, 591)
- Introduce
Literal
,TypedDict
,Final
,Protocol
# Python 3.8
from typing import Literal, TypedDict, Protocol, List
TaskPriority = Literal["low", "medium", "high"]
class TaskDict(TypedDict):
id: int
title: str
completed: bool
task_data: TaskDict = {"id": 1, "title": "Buy", "completed": False}
class Completable(Protocol):
def mark_complete(self) -> None: ...
def complete_items(items: List[Completable]) -> None:
for item in items:
item.mark_complete()
Python 3.9 (PEP 585)
- Sintaxis nativa para tipos genéricos (preferida)
# Antes (Python <= 3.8)
# from typing import List, Dict, Tuple
# tasks: List[Dict[str, Any]] = []
# Después (Python 3.9+)
tasks: list[dict[str, Any]] = []
task_tags: dict[int, list[str]] = {1: ["shopping"]}
task_location: tuple[float, float] = (40.7, -74.0)
Python 3.10 (PEP 604, 612, 613)
- Operador
|
para uniones (Union
) ParamSpec
yConcatenate
paraCallable
- Alias de tipos con
TypeAlias
(aunquetype
es preferido en 3.12+)
# Antes (Python <= 3.9)
# from typing import Union, Optional
# task_id: Union[int, str] = "task-1"
# due_date: Optional[str] = None
# Después (Python 3.10+)
task_id: int | str = "task-1"
due_date: str | None = None
Python 3.11 (PEP 646, 655, 673, 675)
Self
para referirse al tipo de la clase actualLiteralString
para seguridad (ej., prevenir inyección SQL)NotRequired
yRequired
paraTypedDict
- Grupos de excepciones y
except*
TypeVarTuple
para genéricos variádicos
# Python 3.11
from typing import Self, LiteralString, NotRequired, TypedDict
class Task:
# ...
def set_title(self, title: str) -> Self:
self.title = title
return self
def search_tasks(query: LiteralString) -> list: # Acepta solo literales de string
# sql = f"SELECT * FROM tasks WHERE title LIKE '%{query}%'" # Seguro
return []
class TaskData(TypedDict):
id: int
title: str
priority: NotRequired[int] # Campo opcional
Python 3.12 (PEP 695, 696, 698)
- Sintaxis
type
para alias de tipos (más limpia) - Sintaxis simplificada para genéricos
- Decorador
@override
para verificación estática
# Python 3.12
# Alias de tipos
type TaskId = int | str
type Coordinate = tuple[float, float]
# TypedDict con sintaxis de 'type'
type TaskData = {
"id": int,
"title": str,
"due_date": str | None
}
# Sintaxis genérica simplificada
# class TaskRepository[T]: ...
4. Tipos comunes y del módulo typing
Any
Permite cualquier tipo. Usar con precaución, anula las verificaciones de tipo.
from typing import Any
def process_unstructured_data(data: Any) -> None:
print(f"Processing: {data}")
process_unstructured_data({"id": 1}) # OK
process_unstructured_data("raw data") # OK
Union
(|
) y Optional
(| None
)
Se prefiere el operador |
(Python 3.10+).
# Python 3.10+
task_id: int | str = "task-abc"
due_date: str | None = None # Puede ser string o None
def print_due_date(due_date: str | None) -> None:
if due_date is not None:
print(f"Due: {due_date}") # El type checker sabe que due_date es str aquí
else:
print("No due date.")
Callable
Para anotar funciones o métodos pasados como argumentos.
from typing import Callable
# predicate espera una función que toma un dict y devuelve bool
def filter_tasks(tasks: list[dict], predicate: Callable[[dict], bool]) -> list[dict]:
return [task for task in tasks if predicate(task)]
def is_completed(task: dict) -> bool:
return task.get("completed", False)
# completed_tasks = filter_tasks(tasks, is_completed)
# overdue_tasks = filter_tasks(tasks, lambda t: t.get("due_date") < today)
Colecciones (sintaxis moderna - Python 3.9+)
task_ids: list[int] = [1, 2, 3]
task_by_id: dict[int, dict] = {1: {"title": "Buy groceries"}}
coordinates: tuple[float, float] = (40.7128, -74.0060)
unique_tags: set[str] = {"shopping", "urgent"}
5. Tipos avanzados
TypeVar
(genéricos)
Para crear funciones o clases que operan sobre diferentes tipos.
from typing import TypeVar, Generic
T = TypeVar('T') # Declara una variable de tipo genérico 'T'
class Repository(Generic[T]):
def __init__(self) -> None:
self._items: list[T] = []
def add(self, item: T) -> None:
self._items.append(item)
def get(self, index: int) -> T | None:
try:
return self._items[index]
except IndexError:
return None
# Uso con tipos específicos
# class Task: ...
# class User: ...
# task_repo: Repository[Task] = Repository()
# user_repo: Repository[User] = Repository()
TypedDict
Para diccionarios con una estructura fija y conocida.
from typing import TypedDict, NotRequired # NotRequired desde Python 3.11
class TaskData(TypedDict):
id: int
title: str
completed: bool
due_date: str | None
# Campos opcionales con NotRequired
priority: NotRequired[int]
assignee: NotRequired[str]
task_info: TaskData = {
"id": 101,
"title": "Refactor module",
"completed": False,
"due_date": None,
"priority": 2 # Opcional
# "assignee" no es requerido
}
print(task_info["title"]) # Acceso seguro, el editor conoce los campos
# print(task_info["non_existent_key"]) # Error del type checker
Literal
Para restringir una variable a un conjunto específico de valores literales.
from typing import Literal
TaskStatus = Literal["pending", "in_progress", "completed", "archived"]
TaskPriority = Literal["low", "medium", "high"]
def set_task_status(task_id: int, status: TaskStatus) -> None:
print(f"Setting task {task_id} status to {status}")
set_task_status(1, "in_progress") # OK
# set_task_status(2, "done") # Error del type checker: "done" no es un TaskStatus válido
6. Validación estática (type checking)
Herramientas como mypy
y pyright
/pylance
(integrado en VSCode/Cursor) analizan el código sin ejecutarlo para encontrar errores de tipo.
Ejemplo (mypy
):
# archivo: task_operations.py
def mark_task_done(task_id: int) -> None:
print(f"Marking task {task_id} as done.")
# Lógica para actualizar estado...
# Llamada incorrecta
mark_task_done("TASK-001") # Se espera int, se pasa str
Ejecutar mypy task_operations.py
en la terminal:
task_operations.py:6: error: Argument 1 to "mark_task_done" has incompatible type "str"; expected "int" [arg-type]
Found 1 error in 1 file (checked 1 source file)
Integrar estas herramientas en el flujo de desarrollo (IDE, pre-commit hooks, CI/CD) es crucial para detectar errores tempranamente.
7. Validación en tiempo de ejecución
Verifican los tipos mientras el código se ejecuta, útil para datos externos (APIs, entrada de usuario).
typeguard
Usa un decorador @typechecked
para añadir validaciones basadas en las anotaciones.
# pip install typeguard
from typeguard import typechecked
@typechecked
def create_priority_task(title: str, priority: int) -> dict:
if not (1 <= priority <= 5):
raise ValueError("Priority must be between 1 and 5")
return {"title": title, "priority": priority}
create_priority_task("Urgent task", 3) # OK
# create_priority_task("Another task", "high") # Lanza TypeError en tiempo de ejecución debido a @typechecked
# create_priority_task("Low prio", 6) # Lanza ValueError (validación manual)
pydantic
Ideal para validación de datos, serialización y configuración. Define modelos de datos usando anotaciones de tipo.
# pip install pydantic
from pydantic import BaseModel, Field, ValidationError
from datetime import date
class Task(BaseModel):
id: int | None = None
title: str = Field(..., min_length=1) # Campo requerido con longitud mínima
description: str = ""
due_date: date | None = None
completed: bool = False
tags: list[str] = []
priority: int = Field(default=1, ge=1, le=5) # Valor por defecto y rango (>=1, <=5)
# Validación y coerción automática al crear instancias
try:
task1 = Task(title="Buy groceries", priority="3") # 'priority' se convierte a int
print(task1.priority) # 3 (int)
task2 = Task(id="101", title="Clean room") # 'id' se convierte a int
print(task2.id) # 101 (int)
# Error de validación: priority fuera de rango
# Task(title="Invalid Task", priority=10)
# Error de validación: title es requerido y vacío
# Task(title="")
except ValidationError as e:
print(e)
# Pydantic es muy usado en APIs (FastAPI) para validación de requests/responses
8. Migración progresiva a tipos
Introducir tipos en una base de código existente gradualmente.
Estrategias:
- Empezar por los límites: Anotar primero las funciones públicas, APIs, y puntos de entrada/salida
- Anotar modelos de datos: Definir tipos para las estructuras de datos clave (clases,
TypedDict
) - Usar
Any
temporalmente: Marcar tipos complejos o difíciles de anotar conAny
y refinar más tarde - Enfocarse en código nuevo/modificado: Asegurarse de que todo el código nuevo o refactorizado tenga tipos
- Integrar type checkers temprano: Ejecutar
mypy
opyright
regularmente, incluso si reportan muchos errores al principio. Configurar para ignorar ciertos archivos/errores temporalmente si es necesario
Archivos stub (.pyi
)
Permiten añadir anotaciones de tipo a bibliotecas o módulos de terceros (o código propio) sin modificar el código fuente original. Son archivos de interfaz que solo contienen las firmas de tipo.
# archivo: external_library.py (código sin tipos)
# def process_data(raw_data):
# # ... lógica compleja ...
# return {"result": "processed"}
# archivo: external_library.pyi (archivo stub con tipos)
from typing import Any, Dict
def process_data(raw_data: bytes) -> Dict[str, Any]: ... # El '...' indica que la implementación está en otro lugar
Los type checkers usarán el archivo .pyi
para verificar las llamadas a external_library
.
9. Errores comunes y buenas prácticas
-
Verificar
None
: Antes de usar una variable que puede serNone
(Optional[T]
oT | None
), verifica si no esNone
.def get_due_date_str(task: dict) -> str: due_date: str | None = task.get("due_date") if due_date is not None: # El type checker sabe que due_date es str aquí return f"Due: {due_date.upper()}" # Ejemplo de uso seguro return "No due date set."
-
Usar alias de tipos: Simplifica anotaciones complejas y mejora la legibilidad.
# Python 3.12+ type TaskId = int | str type TaskDict = dict[str, Any] type UserId = int type UserTaskMap = dict[UserId, list[TaskDict]] def process_user_tasks(mapping: UserTaskMap) -> None: ...
-
Evitar
Any
excesivo: Reduce los beneficios del tipado. Usar solo cuando sea necesario (datos muy dinámicos, migración gradual) -
Ser específico: Preferir
list[int]
sobrelist
,dict[str, int]
sobredict
-
Anotar retornos: Siempre anotar el tipo de retorno de las funciones (
-> Type
). Anotar con-> None
si la función no retorna nada explícitamente -
Consistencia: Mantener un estilo consistente de anotaciones en todo el proyecto
10. Mejora en editores (integración con IDEs)
Las anotaciones de tipo potencian las capacidades de los editores modernos:
- Autocompletado preciso: Sugerencias basadas en el tipo inferido o anotado
- Detección de errores en vivo: Subrayado de errores de tipo mientras escribes
- Información al pasar el cursor (tooltips): Muestra la firma de la función y los tipos esperados/retornados
- Navegación de código: Ir a la definición/referencias de tipos
- Refactorización segura: Renombrar variables o extraer métodos con mayor confianza
class Task:
title: str
completed: bool = False
def __init__(self, title: str):
self.title = title
def complete_task(task: Task) -> None:
# Al escribir 'task.', el editor sugerirá 'title' y 'completed'
if not task.completed:
task.completed = True
# Al escribir 'task.title.', el editor sugerirá métodos de string
print(f"Task '{task.title.upper()}' marked as complete.")
my_task = Task("Review documentation")
# Al escribir 'complete_task(', el editor mostrará que espera un argumento 'task' de tipo 'Task'
complete_task(my_task)
11. Futuro del typing (Python 3.12+)
El sistema de tipos sigue evolucionando:
- Optimización: Las anotaciones tienen menos impacto en el rendimiento en tiempo de ejecución
- Nuevas sintaxis (PEP 695):
type
para alias y sintaxis simplificada para genéricos (class List[T]: ...
) - Verificación de
@override
(PEP 698): Ayuda a asegurar que un método en una subclase realmente sobrescribe un método de la superclase assert_type
(typing): Permite verificaciones explícitas de tipo en el código que los type checkers pueden validar
# Python 3.12+
from typing import assert_type
type TaskList = list[Task] # Usando alias con 'type'
def get_pending_tasks() -> TaskList:
# ... Lógica para obtener tareas ...
# return [...]
pass # Placeholder
# Verificación estática con assert_type
pending: TaskList = get_pending_tasks()
assert_type(pending, list[Task]) # mypy/pyright verificará esto
# assert_type(pending, list[str]) # Esto daría un error en el type checker
12. Conclusiones
Las anotaciones de tipo en Python, aunque opcionales, ofrecen beneficios sustanciales para crear software robusto y mantenible:
- Claridad: El código se autodocumenta parcialmente
- Robustez: Detección temprana de errores de tipo mediante análisis estático
- Productividad: Mejoras en herramientas de desarrollo (autocompletado, refactorización)
- Mantenibilidad: Facilita la comprensión y modificación segura del código
Adoptar tipos puede hacerse gradualmente, empezando por las partes más críticas o nuevas del código, y utilizando herramientas como mypy
, pyright
, y opcionalmente pydantic
o typeguard
para validación. Es una inversión que mejora la calidad del código y la eficiencia del desarrollo.
Recursos adicionales
- Documentación oficial del módulo
typing
- PEP 484 – Type Hints (El PEP original)
- Documentación de
mypy
- Documentación de
pydantic
- Real Python - Python Type Checking Guide
Recuerda que el código de ejemplo completo está en el repositorio vinculado al inicio del artículo.