Introducción a Type Hinting
Añade tipos estáticos a Python para código más robusto y mantenible
Python es un lenguaje de tipado dinámico, lo que significa que las variables no tienen tipos fijos y pueden cambiar durante la ejecución. Sin embargo, desde Python 3.5 (PEP 484), podemos añadir anotaciones de tipos a nuestro código para mejorar la legibilidad, facilitar la detección de errores y habilitar herramientas de análisis estático.
En este capítulo exploraremos: fundamentos de type hinting, el módulo typing, tipos genéricos y avanzados, verificación con mypy y mejores prácticas para escribir código Python tipado profesionalmente.
¿Qué es Type Hinting?
Las anotaciones de tipo (type hints) son metadatos que indican qué tipos de datos esperan las funciones y variables. Python no las ejecuta en tiempo de ejecución, pero herramientas como mypy las utilizan para verificar la consistencia del código antes de ejecutarlo.
Beneficios
Sintaxis Básica
Las anotaciones usan dos puntos : para variables y parámetros, y -> para el tipo de retorno:
# Anotaciones en variables name: str = "Alice" age: int = 30 height: float = 1.75 is_active: bool = True # Anotaciones en funciones def greet(name: str) -> str: return f"Hello, {name}!" # Función con múltiples parámetros def add_numbers(a: int, b: int) -> int: return a + b # Uso result = greet("Python") print(result) # Hello, Python! total = add_numbers(5, 3) print(total) # 8
8
El Módulo typing
El módulo typing proporciona tipos adicionales para casos más complejos que los tipos básicos de Python. Fue introducido en Python 3.5 y ha evolucionado significativamente con cada versión.
Tipos de Colecciones
from typing import List, Dict, Set, Tuple # Lista de strings names: List[str] = ["Alice", "Bob", "Charlie"] # Diccionario string -> int ages: Dict[str, int] = {"Alice": 30, "Bob": 25} # Conjunto de enteros unique_ids: Set[int] = {1, 2, 3, 4, 5} # Tupla con tipos específicos por posición coordinate: Tuple[float, float] = (10.5, 20.3) # Tupla de longitud variable (mismo tipo) numbers: Tuple[int, ...] = (1, 2, 3, 4, 5) def process_users(users: List[str]) -> Dict[str, int]: return {user: len(user) for user in users} result = process_users(names) print(result) # {'Alice': 5, 'Bob': 3, 'Charlie': 7}
list[str], dict[str, int], set[int]. El módulo typing sigue siendo necesario para tipos más avanzados.
Optional y Union
Para valores que pueden ser de múltiples tipos o None:
from typing import Optional, Union # Optional[X] es equivalente a Union[X, None] def find_user(user_id: int) -> Optional[str]: users = {1: "Alice", 2: "Bob"} return users.get(user_id) # Puede retornar str o None # Union permite múltiples tipos def process_input(value: Union[str, int]) -> str: if isinstance(value, int): return f"Number: {value}" return f"String: {value}" # Python 3.10+ sintaxis con | def modern_process(value: str | int) -> str: return str(value) # Ejemplos de uso print(find_user(1)) # Alice print(find_user(99)) # None print(process_input(42)) # Number: 42 print(process_input("hello")) # String: hello
Tabla de Tipos Comunes
| Tipo | Descripción | Ejemplo |
|---|---|---|
List[T] |
Lista de elementos tipo T | List[int] |
Dict[K, V] |
Diccionario con claves K y valores V | Dict[str, float] |
Optional[T] |
T o None | Optional[str] |
Union[A, B] |
A o B | Union[int, str] |
Tuple[A, B] |
Tupla con tipos fijos | Tuple[str, int, float] |
Callable[[Args], R] |
Función con argumentos y retorno | Callable[[int], str] |
Any |
Cualquier tipo (escape hatch) | Any |
Tipos Avanzados
El módulo typing ofrece tipos más sofisticados para casos de uso avanzados como funciones de orden superior, clases genéricas y restricciones de tipos.
Callable: Funciones como Tipos
from typing import Callable, List # Tipo para funciones que toman int y retornan str Formatter = Callable[[int], str] def apply_formatter( numbers: List[int], formatter: Formatter ) -> List[str]: return [formatter(n) for n in numbers] # Diferentes formateadores def hex_format(n: int) -> str: return hex(n) def binary_format(n: int) -> str: return bin(n) nums = [10, 20, 30] print(apply_formatter(nums, hex_format)) print(apply_formatter(nums, binary_format))
['0b1010', '0b10100', '0b11110']
TypeVar: Tipos Genéricos
Los TypeVar permiten crear funciones y clases genéricas que mantienen relaciones entre tipos:
from typing import TypeVar, List, Sequence # TypeVar para tipo genérico T = TypeVar('T') def first_element(items: Sequence[T]) -> T: """Retorna el primer elemento manteniendo el tipo.""" return items[0] # El tipo de retorno se infiere automáticamente strings: List[str] = ["a", "b", "c"] numbers: List[int] = [1, 2, 3] first_str = first_element(strings) # tipo: str first_num = first_element(numbers) # tipo: int print(first_str, type(first_str)) print(first_num, type(first_num)) # TypeVar con restricciones Number = TypeVar('Number', int, float) def double(value: Number) -> Number: return value * 2 print(double(5)) # 10 (int) print(double(3.14)) # 6.28 (float)
Literal: Valores Específicos
from typing import Literal # Solo acepta estos valores específicos Direction = Literal["north", "south", "east", "west"] def move(direction: Direction, steps: int) -> str: return f"Moving {steps} steps {direction}" print(move("north", 10)) # OK print(move("east", 5)) # OK # move("up", 3) # ERROR: mypy detectará esto # Útil para estados y configuraciones LogLevel = Literal["DEBUG", "INFO", "WARNING", "ERROR"] def log(message: str, level: LogLevel = "INFO") -> None: print(f"[{level}] {message}")
Literal es especialmente útil para APIs donde solo ciertos valores son válidos, como modos de archivo ("r", "w", "a") o estados de proceso.
TypedDict y Data Classes
Python ofrece formas elegantes de definir estructuras de datos con tipos claros: TypedDict para diccionarios con estructura fija y dataclass para clases de datos.
TypedDict: Diccionarios Estructurados
from typing import TypedDict, Optional class User(TypedDict): id: int name: str email: str age: Optional[int] # TypedDict con campos opcionales (Python 3.11+) class Config(TypedDict, total=False): debug: bool timeout: int retries: int def greet_user(user: User) -> str: return f"Hello, {user['name']}!" # Uso correcto alice: User = { "id": 1, "name": "Alice", "email": "alice@example.com", "age": 30 } print(greet_user(alice)) # Hello, Alice!
Data Classes con Tipos
Las data classes combinan la definición de clases con type hints de forma elegante:
from dataclasses import dataclass, field from typing import List, Optional @dataclass class Product: """Representa un producto en el inventario.""" id: int name: str price: float quantity: int = 0 tags: List[str] = field(default_factory=list) def total_value(self) -> float: return self.price * self.quantity @dataclass class Order: order_id: int customer: str products: List[Product] discount: Optional[float] = None def total(self) -> float: subtotal = sum(p.total_value() for p in self.products) if self.discount: subtotal *= (1 - self.discount) return subtotal # Uso laptop = Product(1, "Laptop", 999.99, 2, ["electronics", "computers"]) mouse = Product(2, "Mouse", 29.99, 5) order = Order(1001, "Alice", [laptop, mouse], discount=0.1) print(f"Product: {laptop}") print(f"Order total: ${order.total():.2f}")
Order total: $1934.96
Protocols: Duck Typing Estático
Los Protocols (PEP 544) permiten definir interfaces estructurales: un objeto satisface el protocolo si tiene los métodos requeridos, sin necesidad de heredar explícitamente.
from typing import Protocol, List # Definir un Protocol (interfaz estructural) class Drawable(Protocol): def draw(self) -> str: ... # Clases que satisfacen el Protocol (sin herencia) class Circle: def __init__(self, radius: float): self.radius = radius def draw(self) -> str: return f"○ Circle(r={self.radius})" class Rectangle: def __init__(self, width: float, height: float): self.width = width self.height = height def draw(self) -> str: return f"▭ Rectangle({self.width}x{self.height})" class Triangle: def __init__(self, base: float): self.base = base def draw(self) -> str: return f"△ Triangle(b={self.base})" # Función que acepta cualquier Drawable def render_all(shapes: List[Drawable]) -> None: for shape in shapes: print(shape.draw()) # Uso - todas las clases satisfacen Drawable shapes: List[Drawable] = [ Circle(5.0), Rectangle(10.0, 20.0), Triangle(15.0) ] render_all(shapes)
▭ Rectangle(10.0x20.0)
△ Triangle(b=15.0)
Verificación con Mypy
mypy es el verificador de tipos estático más popular para Python. Analiza el código sin ejecutarlo y reporta inconsistencias de tipos.
Instalación y Uso
# Instalar mypy $ pip install mypy # Verificar un archivo $ mypy my_script.py # Verificar todo un directorio $ mypy src/ # Modo estricto (más verificaciones) $ mypy --strict my_script.py
Ejemplo de Detección de Errores
def process_name(name: str) -> str: return name.upper() def get_length(items: list[int]) -> int: return len(items) # Errores que mypy detectará: result1 = process_name(123) # Error: int != str result2 = get_length("hello") # Error: str != list[int] def maybe_none() -> str | None: return None value = maybe_none() print(value.upper()) # Error: None no tiene .upper()
errors_demo.py:10: error: Argument 1 to "get_length" has incompatible type "str"; expected "list[int]"
errors_demo.py:16: error: Item "None" of "str | None" has no attribute "upper"
Found 3 errors in 1 file
Configuración con pyproject.toml
[tool.mypy] python_version = "3.12" warn_return_any = true warn_unused_ignores = true disallow_untyped_defs = true strict_optional = true # Ignorar librerías sin stubs [[tool.mypy.overrides]] module = "third_party_lib.*" ignore_missing_imports = true
mypy solo verifica el código, no lo ejecuta. Los errores de tipo son detectados antes de ejecutar, pero no modifican el comportamiento en tiempo de ejecución.
Mejores Prácticas
Aplicar type hints efectivamente requiere balance entre cobertura y practicidad. Aquí están las recomendaciones clave.
Recomendaciones
Any solo como último recurso; elimina los beneficios del tipado.Patrón: Type Aliases
from typing import Dict, List, Tuple, TypeAlias # Type aliases para mejor legibilidad UserId: TypeAlias = int Username: TypeAlias = str Coordinate: TypeAlias = Tuple[float, float] UserCache: TypeAlias = Dict[UserId, Username] # Más legible que Dict[int, Tuple[str, List[Tuple[float, float]]]] RouteData: TypeAlias = Dict[UserId, Tuple[Username, List[Coordinate]]] def get_user_route(data: RouteData, user_id: UserId) -> List[Coordinate]: user_data = data.get(user_id) if user_data: return user_data[1] return [] # Python 3.12+ sintaxis con 'type' type Point = Tuple[float, float] type Vector = List[Point]
Patrón: NewType para Tipos Distintos
from typing import NewType # Crear tipos distintos basados en tipos existentes UserId = NewType('UserId', int) OrderId = NewType('OrderId', int) def get_user(user_id: UserId) -> str: return f"User {user_id}" def get_order(order_id: OrderId) -> str: return f"Order {order_id}" # Uso correcto uid = UserId(123) oid = OrderId(456) print(get_user(uid)) # OK print(get_order(oid)) # OK # mypy detectará este error: # get_user(oid) # Error: OrderId no es UserId
NewType crea tipos semánticamente distintos que mypy diferencia, pero en tiempo de ejecución son equivalentes al tipo base. Ideal para IDs y valores con significado específico.
Resumen de Type Hinting
01 // Tipos Básicos
str, int, float, bool con sintaxis variable: tipo y -> retorno.
02 // Módulo typing
List, Dict, Optional, Union, Callable para tipos complejos.
03 // Genéricos
TypeVar para funciones genéricas, Generic para clases parametrizadas.
04 // Protocols
Interfaces estructurales sin herencia explícita (duck typing estático).
05 // Data Classes
Combina @dataclass con type hints para clases de datos elegantes.
06 // Verificación
mypy para análisis estático. Detecta errores antes de ejecutar.