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 de Task)
  • 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 y Concatenate para Callable
  • Alias de tipos con TypeAlias (aunque type 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 actual
  • LiteralString para seguridad (ej., prevenir inyección SQL)
  • NotRequired y Required para TypedDict
  • 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:

  1. Empezar por los límites: Anotar primero las funciones públicas, APIs, y puntos de entrada/salida
  2. Anotar modelos de datos: Definir tipos para las estructuras de datos clave (clases, TypedDict)
  3. Usar Any temporalmente: Marcar tipos complejos o difíciles de anotar con Any y refinar más tarde
  4. Enfocarse en código nuevo/modificado: Asegurarse de que todo el código nuevo o refactorizado tenga tipos
  5. Integrar type checkers temprano: Ejecutar mypy o pyright 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 ser None (Optional[T] o T | None), verifica si no es None.

    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] sobre list, dict[str, int] sobre dict

  • 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

Recuerda que el código de ejemplo completo está en el repositorio vinculado al inicio del artículo.