volver al blog

Type hints en Python: el contrato que tu código necesitaba

pythontipadobuenas-prácticas

Python nunca fue “sin tipos”

Ese es el malentendido de fondo. Python siempre tuvo tipos: lo que no tenía era la costumbre de escribirlos. Cada vez que una función recibía un str y devolvía un int, ese contrato existía. Vivía en tu cabeza, en un docstring que nadie actualizaba o, con suerte, en un test. Los type hints solo hacen una cosa: sacan ese contrato de tu cabeza y lo ponen donde el editor, el linter y la siguiente persona pueden leerlo.

La duda habitual es si merecen la pena en un lenguaje dinámico. La respuesta corta: en cuanto tu proyecto pasa de un script a algo que otra persona (o tu yo de dentro de seis meses) va a tocar, sí.

Qué te dan y qué no

Conviene ser honesto desde el principio. Los type hints no se comprueban en tiempo de ejecución. Este código corre sin quejarse:

def suma(a: int, b: int) -> int:
    return a + b

suma("hola", "mundo")  # devuelve "holamundo", Python ni pestañea

El intérprete ignora las anotaciones. Quien las usa es un verificador estático como mypy o pyright, que lee tu código sin ejecutarlo y te avisa antes de que llegue a producción. Es la diferencia entre descubrir un fallo en tu editor y descubrirlo en un log a las tres de la mañana.

Lo que sí te dan, y no es poco:

  • Autocompletado que acierta, porque el editor sabe qué es cada cosa.
  • Errores de tipo detectados en CI, no en producción.
  • Documentación que no miente, porque el propio compilador la valida.
  • Refactors sin miedo: cambias una firma y ves al instante qué se rompe.

Del Dict[str, Any] al tipo que dice algo

El error más común al empezar es anotar por anotar. Un dict sin más no aporta nada; peor aún, Any desactiva toda la comprobación y te da una falsa sensación de rigor.

Compara estos dos enfoques para lo mismo:

# Ruido: dice "es un diccionario", que ya lo veíamos venir
def crear_usuario(datos: dict) -> dict:
    return {"id": 1, **datos}

# Contrato: dice qué campos hay y de qué tipo
from dataclasses import dataclass

@dataclass
class Usuario:
    nombre: str
    email: str
    activo: bool = True

def crear_usuario(nombre: str, email: str) -> Usuario:
    return Usuario(nombre=nombre, email=email)

La segunda versión no es más larga por capricho. Ahora tu editor autocompleta usuario.email, mypy detecta si escribes usuario.emial, y el contrato de la función se lee de un vistazo. El tipo dejó de describir la estructura de datos para describir la intención.

Las herramientas que de verdad usas

No hace falta memorizar el módulo typing entero. Con un puñado de construcciones cubres el 90 % de los casos reales.

from typing import Optional

# Optional[X] es azúcar para "X o None"
def buscar_usuario(id: int) -> Optional[Usuario]:
    ...

# Desde Python 3.10 puedes usar la sintaxis con |, más limpia
def buscar_usuario(id: int) -> Usuario | None:
    ...

Optional merece una mención aparte porque ataca el error de mil millones de dólares: el None inesperado. Si una función puede devolver None, dilo en la firma. mypy te obligará entonces a comprobarlo antes de usar el resultado, y ese AttributeError: 'NoneType' object has no attribute ... deja de aparecer.

Para colecciones, desde Python 3.9 usas los tipos nativos en minúscula, sin importar nada de typing:

def precios_por_producto(pedidos: list[dict[str, float]]) -> dict[str, float]:
    ...

Y cuando necesitas expresar “cualquier cosa que se comporte como”, los protocolos (duck typing con red de seguridad) son la herramienta correcta:

from typing import Protocol

class Cerrable(Protocol):
    def close(self) -> None: ...

def liberar(recurso: Cerrable) -> None:
    recurso.close()  # vale cualquier objeto con un close(), sin herencia

Estático vs dinámico: qué gana cada uno

Sin type hintsCon type hints
El error aparece en producciónEl error aparece en tu editor
El autocompletado adivinaEl autocompletado sabe
El docstring dice -> dict (y miente)La firma dice -> Usuario (y se verifica)
Refactorizar es rezarRefactorizar es leer los avisos de mypy
Onboarding: “pregúntale a Marta qué recibe esto”Onboarding: lo lee en la firma

El coste real está en la disciplina inicial y en algún caso puntual donde el sistema de tipos se te resiste. La ganancia se paga sola en cuanto el proyecto tiene más de un archivo y más de una persona.

Cómo lo introduzco en un proyecto que ya existe

Nadie tipa 40.000 líneas en una tarde, y no hace falta. La estrategia que me funciona es gradual y por prioridad.

Primero, mypy en modo permisivo, sin romper el build:

# pyproject.toml
[tool.mypy]
python_version = "3.12"
ignore_missing_imports = true
# Empieza flojo; sube el listón cuando el módulo esté limpio
check_untyped_defs = false

Después, tipa de fuera hacia dentro. Las fronteras del sistema son las que más se benefician: los endpoints de tu API, las funciones que hablan con la base de datos, los puntos donde entran datos externos. Ahí es donde un tipo mal entendido hace más daño. El núcleo lógico interno puede esperar.

Por último, activa disallow_untyped_defs módulo a módulo, según los vas dejando cubiertos. Así el tipado se convierte en un trinquete: lo que ya está limpio no vuelve a ensuciarse, y CI lo garantiza.

Un apunte de campo: si usas FastAPI o Pydantic, ya estás tipando aunque no lo llames así. Esos frameworks usan las anotaciones para validar y serializar en tiempo de ejecución. Ahí el type hint no es documentación, es lógica de negocio.

El contrato, otra vez

Los type hints no convierten Python en Java ni te quitan el dinamismo cuando lo necesitas. Hacen algo más modesto y más útil: escriben el contrato que ya existía de forma implícita, y ponen a una máquina a comprobarlo por ti.

Si tienes que quedarte con un solo paso: instala mypy, apúntalo a tu módulo más crítico y arregla lo que te diga. No hace falta más para empezar a dormir mejor.