Type hints en funciones: sintaxis, semántica y uso real

Los type hints son anotaciones que describes en la firma de una función para indicar qué tipos esperas recibir y qué tipo devuelves. La sintaxis es def f(x: int, y: str) -> bool: — los dos puntos después del parámetro para entradas, la flecha para la salida. Simple de leer, pero hay una trampa mental que atrapa a casi todos la primera vez: Python no hace nada con esas anotaciones en tiempo de ejecución.

El intérprete las evalúa (o en algunos contextos, las guarda como strings sin evaluarlas), las almacena en el atributo __annotations__ de la función, y sigue adelante. Si pasas un float donde anotaste int, Python no lanza ninguna excepción. No hay coerción, no hay validación implícita. Las anotaciones existen exclusivamente para herramientas externas: type checkers como mypy o pyright, IDEs, linters, y sistemas de documentación.

Esto es un diseño deliberado. Python tomó la decisión de mantener los type hints como información opcional y sin coste en runtime, en lugar de convertirlos en un sistema de tipos estricto que rompería la filosofía de duck typing del lenguaje. El PEP 484 (2014) lo dejó claro: las anotaciones son una capa de documentación verificable, no un contrato ejecutado.

¿Cuándo valen la pena? En código que otros van a consumir — APIs públicas de módulos, funciones en equipos grandes, librerías — los type hints son documentación que un type checker puede verificar de forma continua. En un script exploratorio de 40 líneas o un notebook de análisis de datos que vas a tirar mañana, añaden ruido sin beneficio real.

El riesgo de hacerlo mal no es un crash en producción — es falsa confianza. Si anotas una función y asumes que está “tipada y segura”, pero nadie ejecuta mypy en el proyecto, tienes el costo de escritura sin el beneficio de la verificación.

from __future__ import annotations  # (1)

from collections.abc import Sequence
from typing import Optional


def find_first(
    items: Sequence[str],
    target: str,
    case_sensitive: bool = True,
) -> int | None:  # (2)
    """Return the index of the first occurrence of target, or None."""
    for i, item in enumerate(items):
        candidate = item if case_sensitive else item.lower()
        needle = target if case_sensitive else target.lower()
        if candidate == needle:
            return i
    return None


def summarize(
    values: Sequence[float],
    label: Optional[str] = None,  # (3)
) -> dict[str, float]:
    if not values:
        raise ValueError("Cannot summarize an empty sequence")

    result: dict[str, float] = {
        "min": min(values),
        "max": max(values),
        "mean": sum(values) / len(values),
    }

    if label is not None:
        # Store label length as a float to keep the return type homogeneous
        result["label_len"] = float(len(label))

    return result


# __annotations__ es un dict ordinario — accesible en runtime
print(find_first.__annotations__)
# {'items': 'Sequence[str]', 'target': 'str',
#  'case_sensitive': 'bool', 'return': 'int | None'}

# Nada impide esto en runtime — mypy sí lo detectaría
print(find_first([1, 2, 3], "a"))  # type: ignore[arg-type]
  1. from __future__ import annotations activa la evaluación diferida (PEP 563): todas las anotaciones se guardan como strings en lugar de evaluarse al definir la función. Esto evita errores de referencias circulares y mejora el tiempo de importación en módulos con muchas anotaciones.

  2. int | None es la sintaxis union disponible desde Python 3.10. En versiones anteriores necesitas Optional[int] de typing, que es exactamente equivalente.

  3. Optional[str] aparece aquí intencionadamente junto a int | None para que veas ambas formas. En proyectos que usan Python ≥ 3.10, la sintaxis X | None es preferible por ser más directa.


Errores que debes conocer

Error: Asumir que el type hint valida el valor en runtime y omitir validación explícita.

# ❌ Wrong
def process_age(age: int) -> str:
    return f"Tienes {age * 12} meses"

process_age("treinta")  # Falla con TypeError en el *, no antes

# ✅ Right
def process_age(age: int) -> str:
    if not isinstance(age, int):
        raise TypeError(f"age debe ser int, recibido {type(age).__name__!r}")
    return f"Tienes {age * 12} meses"

El hint documenta la intención, pero si la función es parte de una API pública que recibe datos de fuentes externas, la validación explícita con isinstance sigue siendo necesaria.


Error: Usar list[str] en lugar de Sequence[str] cuando solo necesitas iterar, acoplando innecesariamente a una implementación concreta.

# ❌ Wrong — rechaza tuplas, generadores y cualquier otra secuencia
def first_upper(items: list[str]) -> str:
    return items[0].upper()

# ✅ Right — acepta cualquier cosa con __getitem__ y __len__
from collections.abc import Sequence

def first_upper(items: Sequence[str]) -> str:
    return items[0].upper()

Sequence de collections.abc es el tipo correcto cuando solo lees la secuencia; reserva list para cuando realmente necesitas mutarla.


Error: Anotar variables locales triviales hasta el punto de hacer el código más difícil de leer que sin anotaciones.

# ❌ Wrong — ruido sin valor
def double(x: int) -> int:
    result: int = x * 2
    temp: int = result + 0
    return temp

# ✅ Right — anota la firma, deja que la inferencia haga su trabajo
def double(x: int) -> int:
    return x * 2

Los type checkers infieren tipos locales perfectamente bien; las anotaciones de variables locales solo aportan valor cuando la inferencia no puede resolver el tipo o cuando la asignación inicial es None y el tipo real llega después.

58

Dejar un comentario

Tu dirección de correo electrónico no será publicada. Los campos obligatorios están marcados con *

Scroll al inicio