Type hints en Python — La guía práctica para escribir código que se lee solo

Python es un lenguaje de tipado dinámico. Eso significa que al escribir una variable, no le dices de qué tipo es: lo descubre solo en tiempo de ejecución. Para juguetes y scripts pequeños, fantástico — escribes rápido y poco. Para proyectos serios, ese mismo dinamismo es el origen de la mitad de tus bugs: una función que esperaba un int recibe None, una lista de strings llega como dict, y el bug aparece tres niveles de llamadas más abajo.
La solución que Python adoptó desde la versión 3.5 son los type hints: anotaciones que dicen el tipo esperado de cada parámetro y de los valores devueltos. No cambian el comportamiento del código: Python las ignora en runtime. Pero los IDEs, linters y herramientas como mypy o pyright las usan para detectar errores antes de ejecutar.
En esta entrada te enseño la sintaxis básica, los casos típicos (listas, dicts, opcionales, uniones), cómo aplicarlos a clases y dataclasses, y cómo poner un type checker en tu workflow para que te avise antes de que el bug llegue a producción.
Contenido
- 1 La idea en 30 segundos
- 2 Anotar variables y funciones
- 3 Tipos básicos: int, str, bool, float, list, dict, tuple, set
- 4 Optional, None y Union
- 5 Any (la salida de emergencia) y object
- 6 Anotar clases y dataclasses
- 7 Anotar callables (funciones que reciben otras funciones)
- 8 Anotar genéricos (cuando un tipo depende de otro)
- 9 Patrón pro: Self para métodos que devuelven la propia clase
- 10 Verificar tipos con mypy o pyright
- 11 Casos reales típicos
- 12 Errores típicos al usar type hints
- 13 Resumen
- 14 Tu siguiente paso
La idea en 30 segundos
Sin type hints:
def saludar(nombre):
return f"Hola, {nombre}"
Con type hints:
def saludar(nombre: str) -> str:
return f"Hola, {nombre}"
nombre: str dice “espero un string”. -> str dice “devuelvo un string”. Eso es todo. Tu IDE muestra autocompletado mejor, los linters detectan llamadas inválidas, y la función se documenta sola.
💡 Importante: Python NO valida los tipos en runtime. Si llamas
saludar(42), el intérprete no se queja — solo el type checker te avisaría antes. Los hints son metadatos, no validación.
Anotar variables y funciones
# Variables
edad: int = 30
nombre: str = "Ana"
activo: bool = True
precios: list[float] = [9.99, 14.50, 19.99]
# Funciones
def calcular_total(precios: list[float], iva: float = 0.21) -> float:
return sum(p * (1 + iva) for p in precios)
# Sin valor de retorno (None implícito)
def imprimir_resumen(datos: dict) -> None:
print(datos)
⚡ Tip-friki: la sintaxis
list[float],dict[str, int], etc. funciona desde Python 3.9. Antes había que importarList,Dictdetyping(en mayúsculas). Si usas Python moderno, ignora ese mundo.
Tipos básicos: int, str, bool, float, list, dict, tuple, set
def ejemplo(
n: int,
texto: str,
activo: bool,
nombres: list[str],
edades: dict[str, int],
coordenadas: tuple[float, float],
etiquetas: set[str],
) -> None:
...
tuple[float, float] significa exactamente dos floats en orden. Si la tupla es de longitud variable y todos del mismo tipo: tuple[int, ...].
📥 Llévate el cheatsheet de Python (gratis)
PDF de 6 páginas con lo esencial: tipos, condicionales, bucles, estructuras de datos, funciones y los errores que más vas a cometer. Para tener al lado mientras programas.
Sin spam. Te apuntas a la lista, descargas el cheatsheet y recibes contenido de Python cada semana.
Optional, None y Union
¿Y cuando una función puede recibir None? Hay tres formas de expresarlo:
from typing import Optional, Union
# 1. Forma moderna (Python 3.10+) — preferida
def buscar(id: int) -> str | None:
...
# 2. Optional[X] = X | None
def buscar(id: int) -> Optional[str]:
...
# 3. Union de varios tipos
def parsear(valor: str | int | float) -> str:
...
# Equivalente con typing.Union (Python 3.9 y anteriores):
def parsear(valor: Union[str, int, float]) -> str:
...
💡 La sintaxis
X | Yes lo nuevo y limpio.Optional[X]es lo mismo queX | None— un alias histórico.
Any (la salida de emergencia) y object
from typing import Any
def cargar_config(ruta: str) -> Any:
...
Any significa “no compruebes nada aquí, este valor puede ser cualquier cosa”. Útil cuando vienes de JSON sin esquema o trabajas con código legacy. Pero úsalo lo menos posible — si todo es Any, los type hints no te aportan nada.
object es lo opuesto: cualquier cosa, pero el type checker te obliga a verificar antes de usarla. Más estricto.
Anotar clases y dataclasses
Los type hints brillan con clases. Tu IDE autocompleta los atributos al instante:
from dataclasses import dataclass
@dataclass
class Pelicula:
titulo: str
year: int
rating: float
genero: str = "drama"
estrellas: int | None = None
p = Pelicula(titulo="Heat", year=1995, rating=8.3)
print(p.titulo) # IDE autocompleta esto
@dataclass lee los type hints y te genera __init__, __repr__, __eq__ automáticamente. Es el patrón estándar moderno para datos estructurados.
💡 ¿Vienes de JSON y necesitas validación real? Pydantic lleva los type hints un paso más allá: valida en runtime y convierte tipos automáticamente. Mira JSON en Python para el patrón.
Anotar callables (funciones que reciben otras funciones)
Cuando pasas funciones como argumento (decoradores, callbacks, mapeo):
from typing import Callable
def aplicar(funcion: Callable[[int], int], numeros: list[int]) -> list[int]:
return [funcion(n) for n in numeros]
aplicar(lambda x: x * 2, [1, 2, 3]) # [2, 4, 6]
Callable[[int], int] significa “una función que recibe int y devuelve int“.
💡 ¿Quieres entender funciones que reciben funciones? Mira decoradores en Python — el patrón canónico de Callable.
Anotar genéricos (cuando un tipo depende de otro)
Para funciones que funcionan con cualquier tipo pero quieres preservar la coherencia:
from typing import TypeVar
T = TypeVar("T")
def primero(elementos: list[T]) -> T:
return elementos[0]
primero([1, 2, 3]) # int
primero(["a", "b"]) # str
TypeVar("T") es un placeholder: lo que entre como T en el argumento es lo que sale como T en el retorno. El type checker lo verifica.
Patrón pro: Self para métodos que devuelven la propia clase
from typing import Self
class Builder:
def add(self, x: int) -> Self:
...
return self
def build(self) -> "Resultado":
...
Self (Python 3.11+) significa “la propia clase”. Antes había que poner "Builder" como string forward reference o usar TypeVar. Ahora es trivial.
Verificar tipos con mypy o pyright
Los type hints son metadatos hasta que pasas un type checker:
pip install mypy
mypy src/
pip install pyright
pyright src/
Si tu código tiene errores de tipo, el checker te lo dice antes de ejecutar:
def saludar(nombre: str) -> str:
return f"Hola, {nombre}"
saludar(42)
mypy:
error: Argument 1 to "saludar" has incompatible type "int"; expected "str"
💡 Mi recomendación: integrar el type checker en CI. Cada PR pasa por
mypy --strict src/y solo mergea si todo el código está bien tipado. Resultado: la mitad de los bugs no llegan ni a code review.
Casos reales típicos
Función que procesa registros de BBDD
from dataclasses import dataclass
@dataclass
class Usuario:
id: int
email: str
activo: bool
def buscar_usuario(id: int) -> Usuario | None:
...
def listar_activos() -> list[Usuario]:
...
Función con configuración opcional
def conectar(
host: str,
puerto: int = 5432,
timeout: float | None = None,
opciones: dict[str, str] | None = None,
) -> "Conexion":
opciones = opciones or {}
...
Decorador tipado correctamente
from functools import wraps
from typing import Callable, TypeVar, ParamSpec
P = ParamSpec("P")
R = TypeVar("R")
def medir_tiempo(funcion: Callable[P, R]) -> Callable[P, R]:
@wraps(funcion)
def wrapper(*args: P.args, **kwargs: P.kwargs) -> R:
return funcion(*args, **kwargs)
return wrapper
ParamSpec (Python 3.10+) preserva los argumentos del callable original. El decorador tipado bien es uno de los puntos donde se nota la diferencia entre “pythonista intermedio” y “pythonista pro”.
⚡ Para la guía completa de decoradores: decoradores en Python explicados.
Errores típicos al usar type hints
# 1. Confundir tipos en runtime con type hints
def saludar(nombre: str) -> str:
return f"Hola, {nombre}"
saludar(42)
# Python NO se queja en runtime — los hints son metadatos.
# Solo mypy/pyright lo detectarían.
# 2. Forward reference con clase aún no definida
class Nodo:
def siguiente(self) -> Nodo: # ❌ NameError: Nodo no existe todavía
...
class Nodo:
def siguiente(self) -> "Nodo": # ✓ string forward reference
...
# Pythonista 3.10+:
from __future__ import annotations
class Nodo:
def siguiente(self) -> Nodo: # ✓ con __future__ todas las anotaciones son lazy
# 3. Usar List, Dict mayúsculas en Python 3.9+
from typing import List, Dict
def f(items: List[int]) -> Dict[str, int]: ... # ❌ legacy
def f(items: list[int]) -> dict[str, int]: ... # ✓ moderno
# 4. Olvidar Optional/None cuando el valor puede ser None
def buscar(id: int) -> str:
if not encontrado:
return None # ❌ el hint dice str
def buscar(id: int) -> str | None:
...
# 5. Anotar TODO con Any "porque es más fácil"
from typing import Any
def f(x: Any) -> Any: ...
# ❌ pierdes el valor de los hints. Empieza estricto y relaja solo donde toca.
Resumen
| Concepto | Sintaxis |
|---|---|
| Tipo básico | nombre: str, edad: int, activo: bool |
| Lista, dict, set | list[int], dict[str, int], set[str] |
| Tupla fija | tuple[float, float] |
| Opcional / nullable | str | None (3.10+) o Optional[str] |
| Unión | str | int o Union[str, int] |
| Cualquier cosa | Any (úsalo poco) |
| Función como arg | Callable[[int], int] |
| Genérico | TypeVar("T") |
| La propia clase | Self (3.11+) |
| Verificar | mypy src/ o pyright src/ |
¿Te ha valido esto?
Si te ha resultado útil, llévate el cheatsheet de Python en PDF — 6 páginas con tipos, condicionales, bucles, estructuras de datos, funciones y los errores típicos. Para tener al lado mientras programas. Gratis.
Sin spam. Email + cheatsheet + un correo por semana con tutoriales nuevos.
Tu siguiente paso
Si has llegado hasta aquí, ya tienes la base para escribir código Python que se lee solo. La próxima vez que escribas una función, anota los parámetros y el retorno. La próxima vez que crees una clase de datos, hazla @dataclass. Y mete mypy en tu workflow para que te avise antes de que el bug llegue a producción.
Si quieres aprender Python desde la base hasta proyectos reales con tipado, tests y deploy, en El Pythonista lo enseño paso a paso.
Un abrazo,
Oscar
¿Quieres aprender Python en orden, no a saltos?
Esto que has leído es solo una pieza. En El Pythonista lo verás todo encadenado: 11 módulos, 37+ horas de vídeo, 734 actividades y un proyecto real (MovieTracker) que crece contigo desde la primera variable hasta el deploy a producción.
37+ horas · 734 actividades · Proyecto real · Acceso de por vida · 14 días de garantía
