Type hints y mypy en Python: tipado estático opcional

📅 Actualizado en marzo 2026 📊 Nivel: Intermedio ⏱️ 20 min de lectura

Python es dinámico desde su nacimiento: las variables no tienen tipo, las funciones aceptan cualquier argumento, y el intérprete resuelve todo en tiempo de ejecución. Durante décadas eso fue una virtud — menos código, más flexibilidad, prototipado rápido. Pero conforme los proyectos crecen a decenas de módulos y docenas de desarrolladores, esa libertad se convierte en coste: ¿qué devuelve exactamente esta función? ¿Este parámetro puede ser None? ¿Qué tiene este diccionario? Los type hints son la respuesta de Python: tipado opcional que no rompe la dinamicidad pero añade una capa de documentación ejecutable y verificable.

En esta lección aprendemos a anotar código Python con el módulo typing, los tipos compuestos modernos (Python 3.10+), TypeVar, Protocol, dataclass tipadas y TypedDict. Luego configuramos mypy para verificar estáticamente el código antes de ejecutarlo. El resultado: código que se documenta solo, IDEs que autocompletan con precisión quirúrgica, y errores de tipos detectados en el editor en lugar del servidor de producción.

Medallón de Parménides de Elea, filósofo griego presocrático fundador de la ontología y la lógica formal
Τὸ γὰρ αὐτὸ νοεῖν ἐστίν τε καὶ εἶναι
«Pensar y ser son lo mismo»
Parménides de Elea · Filósofo griego · 515 a.C. – 450 a.C.
Parménides formuló el principio más radical de la filosofía presocrática: el ser es, el no-ser no es. Nada puede surgir de la nada, nada puede convertirse en su opuesto. Lo que existe tiene naturaleza fija y necesaria — solo el pensamiento riguroso puede acceder a la verdad, no las apariencias engañosas de los sentidos. Los type hints llevan esa ontología al software: declarar que una función recibe un int y devuelve un str no es una sugerencia, es una afirmación de ser. mypy es el instrumento de razón que verifica esa afirmación: detecta cuando algo que NO ES int intenta pasar donde solo debería existir int, cuando algo que prometía ser str resulta ser None. Python sin tipos opera en el mundo de las apariencias — cualquier cosa puede ser cualquier cosa. Python con tipos opera en el mundo del ser — lo que se declara existe con esa forma, y el no-ser no puede acceder al código que lo rechaza.

¿Por qué anotar tipos en un lenguaje dinámico?

Los type hints (PEP 484, Python 3.5+) son anotaciones que describen los tipos esperados de variables, parámetros y valores de retorno. El intérprete las ignora completamente en tiempo de ejecución — son para herramientas de análisis estático, IDEs y documentación. No cambian el comportamiento del programa, pero sí la calidad del ecosistema que lo rodea.

# ── Sin anotaciones: el misterio ──────────────────────────────
def procesar(datos, config, modo):
    # ¿Qué es datos? ¿una lista? ¿un dict? ¿un DataFrame?
    # ¿config puede ser None? ¿modo es un string o un enum?
    # Solo la documentación (si existe) o leer la implementación responde
    resultado = datos.transform(config, modo)
    return resultado   # ¿qué devuelve?

# ── Con anotaciones: el contrato explícito ────────────────────
from typing import Optional
from dataclasses import dataclass

@dataclass
class Config:
    umbral: float
    modo: str

def procesar(
    datos: list[dict],
    config: Config,
    modo: Optional[str] = None
) -> dict[str, float]:
    # Ahora el IDE sabe exactamente qué métodos/atributos tienen datos y config
    # mypy puede verificar que los tipos sean correctos antes de ejecutar
    # el lector del código entiende el contrato sin leer la implementación
    ...


# ── Los tres beneficios concretos ────────────────────────────
# 1. DOCUMENTACIÓN VIVA: las anotaciones no se quedan obsoletas como
#    los comentarios — si cambias la firma, el type checker avisa

# 2. AUTOCOMPLETADO PRECISO: el IDE conoce el tipo de cada variable
#    y ofrece solo los métodos/atributos válidos

# 3. DETECCIÓN TEMPRANA DE ERRORES:
def calcular_descuento(precio: float, porcentaje: float) -> float:
    return precio * (1 - porcentaje / 100)

# mypy detecta ANTES de ejecutar:
calcular_descuento("19.99", 10)   # ← error: str no es float
calcular_descuento(19.99, "10")   # ← error: str no es float
resultado: int = calcular_descuento(19.99, 10)   # ← error: float no es int


# ── Las anotaciones en tiempo de ejecución: ignoradas ─────────
def saludar(nombre: str) -> str:
    return f"Hola, {nombre}!"

# Python NO comprueba en ejecución:
print(saludar(42))    # funciona: "Hola, 42!" — sin error de runtime
print(saludar(None))  # funciona: "Hola, None!" — sin error de runtime

# Las anotaciones son accesibles como metadatos:
import inspect
print(inspect.get_annotations(saludar))   # {'nombre': , 'return': }
Arquitecto revisando planos técnicos detallados con escuadra y compás sobre mesa de trabajo
Un arquitecto no construye sin planos: los planos son el contrato entre la intención y la ejecución, el documento que permite que albañiles, electricistas y fontaneros trabajen coordinados sin ambigüedad. Los type hints son los planos del código: no construyen nada por sí mismos, pero definen con precisión qué debe ir en cada lugar, qué tipo de material (dato) admite cada componente, y qué produce cada función al final. Sin planos, cada desarrollador interpreta la intención a su manera. Con planos, mypy verifica que nadie esté poniendo un string donde se espera un entero. Fuente: Pexels (licencia libre).

Sintaxis básica: variables, funciones y retornos

La sintaxis de anotación usa : tipo para variables y parámetros, y -> tipo para el retorno de funciones. Desde Python 3.9 los tipos built-in soportan suscripción directa (list[int] en lugar de List[int] del módulo typing). Desde Python 3.10 se puede usar | para unions.

# ── Variables ──────────────────────────────────────────────────
nombre: str      = "Ana"
edad: int        = 32
precio: float    = 19.99
activo: bool     = True
datos: bytes     = b"\x00\xff"

# Anotación sin asignación (declare forward)
resultado: int   # declara que existirá, sin valor aún

# ── Funciones: parámetros y retorno ───────────────────────────
def saludar(nombre: str, veces: int = 1) -> str:
    return (f"Hola, {nombre}! " * veces).strip()

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

def sin_retorno(mensaje: str) -> None:
    print(mensaje)   # None = la función no devuelve valor útil

# ── Colecciones básicas (Python 3.9+) ─────────────────────────
def procesar_nombres(nombres: list[str]) -> list[str]:
    return [n.strip().title() for n in nombres]

def contar_palabras(texto: str) -> dict[str, int]:
    palabras = texto.lower().split()
    return {p: palabras.count(p) for p in set(palabras)}

def primera_ultima(items: tuple[int, ...]) -> tuple[int, int]:
    return items[0], items[-1]

def unicos(valores: set[float]) -> set[float]:
    return {round(v, 2) for v in valores}

# ── Tuplas heterogéneas (longitud fija, tipos distintos) ───────
Coordenada = tuple[float, float]
Rango      = tuple[int, int]         # inicio, fin
RGB        = tuple[int, int, int]    # rojo, verde, azul

def distancia(p1: Coordenada, p2: Coordenada) -> float:
    import math
    return math.sqrt((p2[0]-p1[0])**2 + (p2[1]-p1[1])**2)

# ── Callable: funciones como tipos ────────────────────────────
from typing import Callable

def aplicar(fn: Callable[[int, int], int], a: int, b: int) -> int:
    return fn(a, b)

def aplicar_todos(
    fns: list[Callable[[float], float]],
    valor: float
) -> list[float]:
    return [fn(valor) for fn in fns]

resultado = aplicar(lambda x, y: x + y, 3, 4)   # 7

# ── Python 3.12: type alias explícito ─────────────────────────
type Punto = tuple[float, float]   # nueva sintaxis 3.12
type Tabla = dict[str, list[int]]

# Antes (compatible con versiones anteriores):
from typing import TypeAlias
Punto: TypeAlias = tuple[float, float]

Optional, Union, Any y los tipos compuestos

Optional[X] es el tipo más usado en la práctica — representa un valor que puede ser X o None. Union[X, Y] (o X | Y en Python 3.10+) representa valores que pueden ser de más de un tipo. Any es la válvula de escape: desactiva la verificación de tipos para ese valor.

from typing import Optional, Union, Any, Literal, Final, TypeGuard

# ── Optional: el tipo más frecuente ───────────────────────────
# Optional[X] ≡ Union[X, None] ≡ X | None (Python 3.10+)

def buscar_usuario(id: int) -> Optional[dict]:
    # puede devolver un dict o None si no existe
    ...

def crear_conexion(
    host: str,
    puerto: int = 5432,
    password: str | None = None   # sintaxis moderna
) -> object:
    ...

# ── Narrowing: verificar el tipo antes de usar ────────────────
def mostrar_nombre(usuario: dict | None) -> str:
    if usuario is None:
        return "Anónimo"
    # mypy sabe que aquí usuario es dict (no None)
    return usuario.get("nombre", "Sin nombre")

# ── Union: múltiples tipos posibles ──────────────────────────
def normalizar(valor: int | float | str) -> float:
    if isinstance(valor, str):
        return float(valor.replace(",", "."))
    return float(valor)

# mypy infiere el tipo correcto dentro de cada rama isinstance
def procesar(item: list[int] | dict[str, int] | None) -> int:
    if item is None:
        return 0
    if isinstance(item, list):
        return sum(item)     # mypy sabe: item es list[int]
    return sum(item.values())  # mypy sabe: item es dict[str, int]


# ── Literal: valores exactos permitidos ──────────────────────
from typing import Literal

Modo    = Literal["lectura", "escritura", "append"]
Nivel   = Literal[1, 2, 3]

def abrir_archivo(ruta: str, modo: Modo = "lectura") -> None:
    ...

abrir_archivo("datos.csv", "lectura")    # OK
# abrir_archivo("datos.csv", "borrar")  # error mypy: no es un Literal válido


# ── Final: constantes inmutables ─────────────────────────────
from typing import Final

MAX_REINTENTOS: Final = 3
URL_BASE: Final[str]  = "https://api.ejemplo.com"

# MAX_REINTENTOS = 5  ← mypy error: no se puede reasignar Final


# ── Any: la válvula de escape ─────────────────────────────────
from typing import Any

def log(valor: Any) -> None:
    """Acepta cualquier cosa — usamos Any con intención."""
    print(repr(valor))

# Any desactiva la verificación para ese valor
# Úsalo solo cuando sea inevitable (código muy dinámico, interop)
# Evita usar Any como solución rápida a errores de tipo


# ── TypeGuard: type narrowing personalizado ──────────────────
from typing import TypeGuard

def es_lista_de_str(val: list[Any]) -> TypeGuard[list[str]]:
    """Si devuelve True, mypy sabe que val es list[str]."""
    return all(isinstance(x, str) for x in val)

def procesar_strings(datos: list[Any]) -> list[str]:
    if es_lista_de_str(datos):
        return [s.upper() for s in datos]   # mypy: datos es list[str]
    raise TypeError("Se esperaba list[str]")

Colecciones tipadas: list, dict, tuple y set

Python 3.9+ permite usar directamente los tipos built-in con suscripción. Para código compatible con Python 3.8 o anterior, es necesario importar los equivalentes de typing (List, Dict, Tuple, Set). La sintaxis moderna es mucho más limpia.

from typing import Sequence, Mapping, MutableMapping, Iterable, Iterator

# ── Preferir tipos abstractos en parámetros ───────────────────
# Ser permisivo en lo que aceptas, estricto en lo que produces

# MAL: demasiado concreto en el parámetro
def sumar_lista(nums: list[int]) -> int:
    return sum(nums)
# sumar_lista((1, 2, 3))     ← falla mypy: tuple no es list
# sumar_lista(range(10))     ← falla mypy: range no es list

# BIEN: aceptar cualquier secuencia iterable
def sumar_secuencia(nums: Sequence[int]) -> int:
    return sum(nums)
# sumar_secuencia([1, 2, 3]) ← OK
# sumar_secuencia((1, 2, 3)) ← OK
# sumar_secuencia(range(10)) ← OK

def imprimir_todos(items: Iterable[str]) -> None:
    for item in items:
        print(item)

# ── Mapping vs dict ───────────────────────────────────────────
# Mapping: solo lectura (get, keys, values, items)
def contar_total(precios: Mapping[str, float]) -> float:
    return sum(precios.values())

# MutableMapping: lectura y escritura
def normalizar_precios(precios: MutableMapping[str, float]) -> None:
    for k in precios:
        precios[k] = round(precios[k], 2)

# ── Iterator y Generator ──────────────────────────────────────
from typing import Generator

def contar_hasta(n: int) -> Iterator[int]:
    for i in range(n):
        yield i

# Generator[YieldType, SendType, ReturnType]
def fibonacci() -> Generator[int, None, None]:
    a, b = 0, 1
    while True:
        yield a
        a, b = b, a + b

# ── dict con estructura conocida ──────────────────────────────
# Para dicts con claves conocidas: TypedDict (ver sección siguiente)
# Para dicts homogéneos (todas las claves str, todos los valores int):
Frecuencias = dict[str, int]
Coordenadas = dict[str, float]   # {"x": 1.0, "y": 2.5}

# ── Tipos anidados ────────────────────────────────────────────
Matriz      = list[list[float]]
GrupoAlum   = dict[str, list[str]]   # tutor → [alumnos]
ResultadoDB = list[dict[str, int | str | float | None]]

def transponer(matriz: Matriz) -> Matriz:
    if not matriz:
        return []
    return [[fila[i] for fila in matriz] for i in range(len(matriz[0]))]

# ── Compatibilidad Python 3.8 (typing imports) ────────────────
# Python 3.9+: list[int], dict[str, int], tuple[int, ...]
# Python 3.8-: from typing import List, Dict, Tuple, Set, FrozenSet
# from __future__ import annotations  → activa nueva sintaxis en 3.7+

TypeVar y Generic: tipos polimórficos

Un TypeVar es una variable de tipo que permite escribir funciones y clases que funcionan con múltiples tipos manteniendo la relación entre ellos. Es el mecanismo de generics de Python: la función recibe T y devuelve T — si el input es int, el output también es int.

from typing import TypeVar, Generic

T = TypeVar("T")          # cualquier tipo
N = TypeVar("N", int, float)   # solo int o float (TypeVar acotado)

# ── TypeVar en funciones: preservar el tipo ───────────────────
def primero(lista: list[T]) -> T:
    return lista[0]

n: int = primero([1, 2, 3])     # mypy: devuelve int
s: str = primero(["a", "b"])    # mypy: devuelve str
# i: int = primero(["a", "b"])  # ← error mypy: str no es int


def segundo_elemento(
    par: tuple[T, T]
) -> T:
    return par[1]

x: float = segundo_elemento((1.5, 2.7))  # devuelve float
y: str   = segundo_elemento(("a", "b"))  # devuelve str


# ── TypeVar con cota (bound): restringir a una jerarquía ──────
from typing import Sized

S = TypeVar("S", bound=Sized)  # S debe tener __len__

def mas_largo(a: S, b: S) -> S:
    return a if len(a) >= len(b) else b

resultado_str  = mas_largo("hola", "mundo")    # str
resultado_list = mas_largo([1, 2], [1, 2, 3])  # list[int]


# ── Clase Generic: contenedores tipados ──────────────────────
class Pila(Generic[T]):
    """Pila LIFO genérica."""

    def __init__(self) -> None:
        self._items: list[T] = []

    def push(self, item: T) -> None:
        self._items.append(item)

    def pop(self) -> T:
        if not self._items:
            raise IndexError("Pila vacía")
        return self._items.pop()

    def peek(self) -> T:
        return self._items[-1]

    def __len__(self) -> int:
        return len(self._items)

    def __bool__(self) -> bool:
        return bool(self._items)

# mypy infiere el tipo según el uso:
pila_int: Pila[int] = Pila()
pila_int.push(42)
n: int = pila_int.pop()   # int, no Any

pila_str: Pila[str] = Pila()
pila_str.push("hola")
# pila_str.push(42)  ← error mypy: int no es str


# ── Python 3.12: sintaxis simplificada de generics ────────────
# SIN TypeVar declarado explícitamente:
def primero_nuevo[T](lista: list[T]) -> T:   # Python 3.12+
    return lista[0]

class Cola[T]:   # Python 3.12+
    def __init__(self) -> None:
        self._items: list[T] = []

    def encolar(self, item: T) -> None:
        self._items.append(item)

    def desencolar(self) -> T:
        return self._items.pop(0)


# ── ParamSpec: TypeVar para parámetros de funciones ──────────
from typing import ParamSpec, Concatenate
import functools

P = ParamSpec("P")

def registrar(func: Callable[P, T]) -> Callable[P, T]:
    """Decorador que preserva la firma completa de la función."""
    @functools.wraps(func)
    def wrapper(*args: P.args, **kwargs: P.kwargs) -> T:
        print(f"Llamando a {func.__name__}")
        return func(*args, **kwargs)
    return wrapper

@registrar
def calcular(x: int, y: float, modo: str = "suma") -> float:
    ...

# mypy sabe que calcular tiene exactamente la misma firma que el original
Diagrama del módulo typing de Python: jerarquía de tipos, Optional, Union, TypeVar, Protocol, Generic y TypedDict con ejemplos de uso
El mapa completo del sistema de tipos de Python: la jerarquía desde Any hasta los tipos concretos, cómo Optional y Union amplían los tipos, el papel de TypeVar en los generics, Protocol para duck typing estático, y las estructuras de datos tipadas TypedDict y dataclass. Infografía: Ciberaula.

Protocol: duck typing con verificación estática

Un Protocol define una interfaz estructural: cualquier clase que implemente los métodos especificados es compatible, sin necesidad de herencia explícita. Es la versión estáticamente verificable del duck typing — si camina como un pato y grazna como un pato, mypy lo acepta como pato.

from typing import Protocol, runtime_checkable

# ── Protocol básico ───────────────────────────────────────────
class Serializable(Protocol):
    """Cualquier objeto que pueda convertirse a dict."""
    def to_dict(self) -> dict[str, object]: ...
    def to_json(self) -> str: ...

# Estas clases NO heredan de Serializable:
class Usuario:
    def __init__(self, nombre: str, email: str):
        self.nombre = nombre
        self.email  = email

    def to_dict(self) -> dict[str, object]:
        return {"nombre": self.nombre, "email": self.email}

    def to_json(self) -> str:
        import json
        return json.dumps(self.to_dict())

class Producto:
    def __init__(self, sku: str, precio: float):
        self.sku    = sku
        self.precio = precio

    def to_dict(self) -> dict[str, object]:
        return {"sku": self.sku, "precio": self.precio}

    def to_json(self) -> str:
        import json
        return json.dumps(self.to_dict())

# mypy acepta Usuario y Producto como Serializable porque implementan la interfaz
def guardar(obj: Serializable, ruta: str) -> None:
    with open(ruta, "w") as f:
        f.write(obj.to_json())

guardar(Usuario("Ana", "ana@ejemplo.com"), "usuario.json")   # OK
guardar(Producto("SKU-001", 29.99), "producto.json")          # OK


# ── @runtime_checkable: usar Protocol con isinstance ─────────
@runtime_checkable
class Dibujable(Protocol):
    def dibujar(self) -> None: ...
    def area(self) -> float: ...

class Circulo:
    def __init__(self, radio: float):
        self.radio = radio
    def dibujar(self) -> None:
        print(f"○ Círculo r={self.radio}")
    def area(self) -> float:
        import math
        return math.pi * self.radio ** 2

c = Circulo(5)
print(isinstance(c, Dibujable))   # True (comprueba métodos en runtime)


# ── Protocol con métodos de clase y propiedades ───────────────
class ConNombre(Protocol):
    @property
    def nombre(self) -> str: ...

    @classmethod
    def crear(cls, datos: dict) -> "ConNombre": ...


# ── Protocolo de comparación: Comparable ──────────────────────
from typing import Protocol

class Comparable(Protocol):
    def __lt__(self, other: object) -> bool: ...
    def __le__(self, other: object) -> bool: ...

C = TypeVar("C", bound=Comparable)

def minimo(items: list[C]) -> C:
    """Funciona con cualquier tipo comparable: int, float, str, fecha..."""
    return min(items)

print(minimo([3, 1, 4, 1, 5]))        # 1
print(minimo(["banana", "apple"]))     # 'apple'


# ── Protocol vs ABC: cuándo usar cada uno ─────────────────────
# Protocol → cuando NO controlas las clases (librerías externas)
#            cuando quieres duck typing verificado
#            cuando no necesitas super() ni herencia de implementación
# ABC      → cuando CONTROLAS todas las clases y quieres herencia explícita
#            cuando usas métodos abstractos con implementación parcial
#            cuando necesitas @abstractmethod + implementación default

dataclass y TypedDict: estructuras de datos tipadas

Las dataclass generan automáticamente __init__, __repr__, __eq__ y más a partir de las anotaciones de campo. TypedDict añade tipos a los diccionarios con estructura conocida sin cambiar su tipo en runtime. Juntos eliminan el 90% del boilerplate en la definición de estructuras de datos.

from dataclasses import dataclass, field, KW_ONLY
from typing import TypedDict, NotRequired

# ── dataclass básica ──────────────────────────────────────────
@dataclass
class Punto:
    x: float
    y: float

    def distancia_al_origen(self) -> float:
        import math
        return math.sqrt(self.x**2 + self.y**2)

p = Punto(3.0, 4.0)
print(p)               # Punto(x=3.0, y=4.0)  — __repr__ automático
print(p.distancia_al_origen())   # 5.0
print(Punto(1.0, 2.0) == Punto(1.0, 2.0))   # True — __eq__ automático


# ── dataclass con valores por defecto ─────────────────────────
from datetime import datetime

@dataclass
class Empleado:
    nombre:       str
    email:        str
    departamento: str         = "Sin asignar"
    activo:       bool        = True
    fecha_alta:   datetime    = field(default_factory=datetime.now)
    habilidades:  list[str]   = field(default_factory=list)
    # NO: lista_default: list = []  ← error: mutable default no permitido
    _id: int = field(default=0, repr=False)   # sin repr

e = Empleado("Ana García", "ana@empresa.com", "Ingeniería")
print(e.habilidades)   # []  — lista nueva para cada instancia


# ── dataclass frozen: inmutable ───────────────────────────────
@dataclass(frozen=True)
class Coordenada:
    latitud:  float
    longitud: float
    # e.latitud = 1.0  ← FrozenInstanceError en runtime

# Útil como clave de diccionario (hashable)
cache: dict[Coordenada, str] = {}
cache[Coordenada(40.4168, -3.7038)] = "Madrid"


# ── dataclass con __post_init__: validación ───────────────────
@dataclass
class Rango:
    inicio: int
    fin:    int

    def __post_init__(self) -> None:
        if self.inicio > self.fin:
            raise ValueError(f"inicio ({self.inicio}) > fin ({self.fin})")
        if self.fin - self.inicio > 10000:
            raise ValueError("Rango demasiado amplio")

    def __contains__(self, valor: int) -> bool:
        return self.inicio <= valor <= self.fin

r = Rango(1, 100)
print(5 in r)    # True
print(200 in r)  # False


# ── TypedDict: diccionarios con tipos conocidos ───────────────
class UsuarioAPI(TypedDict):
    id:     int
    nombre: str
    email:  str
    admin:  bool

# Campos opcionales con NotRequired (Python 3.11+)
class ConfigApp(TypedDict):
    debug:   bool
    host:    str
    puerto:  int
    timeout: NotRequired[float]   # opcional
    log_dir: NotRequired[str]     # opcional

# mypy verifica el acceso a claves
def procesar_usuario(usuario: UsuarioAPI) -> str:
    return f"{usuario['nombre']} <{usuario['email']}>"

u: UsuarioAPI = {"id": 1, "nombre": "Ana", "email": "ana@ej.com", "admin": False}
print(procesar_usuario(u))

# Diferencia con dataclass: TypedDict sigue siendo un dict en runtime
print(type(u))   # 
print(u["id"])   # 1
# u["inexistente"]  ← mypy error; en runtime KeyError


# ── Combinar TypedDict y dataclass ───────────────────────────
@dataclass
class Pedido:
    usuario:    UsuarioAPI
    productos:  list[dict[str, float]]   # [{"sku": "...", "precio": ...}]
    total:      float = field(init=False)

    def __post_init__(self) -> None:
        self.total = sum(p["precio"] for p in self.productos)

mypy: verificación estática en la práctica

mypy es el type checker oficial de Python, mantenido por el equipo de Dropbox que propuso el sistema de tipos en PEP 484. Analiza el código sin ejecutarlo y detecta errores de tipos, argumentos incorrectos, atributos inexistentes y retornos incompatibles antes de que lleguen a producción.

# ── Instalación y uso básico ──────────────────────────────────
# pip install mypy

# Verificar un archivo:
#   mypy mi_script.py

# Verificar un módulo completo:
#   mypy mi_paquete/

# Modo estricto (recomendado para proyectos nuevos):
#   mypy --strict mi_script.py


# ── Errores comunes que detecta mypy ─────────────────────────

# 1. Tipo incorrecto en argumento
def cuadrado(n: int) -> int:
    return n * n

cuadrado("5")   # error: Argument 1 has incompatible type "str"; expected "int"


# 2. Atributo inexistente
class Punto:
    def __init__(self, x: float, y: float) -> None:
        self.x = x
        self.y = y

p = Punto(1.0, 2.0)
p.z   # error: "Punto" has no attribute "z"


# 3. Retorno incompatible
def dividir(a: float, b: float) -> float:
    if b == 0:
        return "Error: división por cero"   # error: str no es float
    return a / b


# 4. None no verificado
def obtener_nombre(d: dict) -> str:
    return d.get("nombre")   # error: Optional[str] no es str

# Correcto:
def obtener_nombre_ok(d: dict) -> str:
    return d.get("nombre") or "Desconocido"


# ── Configuración: mypy.ini / pyproject.toml ─────────────────
# [mypy]
# python_version = 3.11
# warn_return_any = True
# warn_unused_configs = True
# disallow_untyped_defs = True
# ignore_missing_imports = True
#
# [mypy-pandas.*]
# ignore_missing_imports = True
# [mypy-requests.*]
# ignore_missing_imports = True

# pyproject.toml equivalente:
# [tool.mypy]
# python_version = "3.11"
# strict = true
# ignore_missing_imports = true


# ── Silencios selectivos: # type: ignore ─────────────────────
import unknown_lib   # type: ignore[import]   # librería sin stubs

resultado: str = funcion_any()   # type: ignore[assignment]


# ── cast: decirle a mypy el tipo exacto ──────────────────────
from typing import cast

datos_raw = obtener_datos_db()   # devuelve Any
datos: list[dict[str, str]] = cast(list[dict[str, str]], datos_raw)
# cast no hace nada en runtime — es solo para mypy


# ── reveal_type: depurar inferencia de mypy ──────────────────
# Solo funciona con mypy, no en runtime normal
x = [1, 2, 3]
reveal_type(x)   # mypy revela: list[int]

# ── Estrategia de adopción gradual ───────────────────────────
# 1. Empezar con: mypy --ignore-missing-imports archivo.py
# 2. Anotar solo funciones públicas / interfaces principales
# 3. Activar: --disallow-untyped-defs para exigir anotaciones
# 4. Activar: --warn-return-any, --strict-optional
# 5. Modo estricto: --strict (equivale a activar todo)
# 6. Añadir a CI/CD: mypy --strict src/ como paso antes de tests
Sistema de clasificación industrial con bandejas de colores diferentes separando piezas por tipo y categoría en línea de producción
Un sistema de clasificación industrial no confía en que los operarios recuerden qué pieza va en qué bandeja: las bandejas tienen formas distintas que hacen físicamente imposible meter la pieza equivocada. El type system de Python es ese sistema: mypy analiza estáticamente el código y detecta cuando un string intenta pasar por donde solo caben enteros, cuando un None quiere acceder a métodos de un objeto, cuando una función promete devolver un float pero puede devolver None en ciertos caminos. No depende de que el desarrollador recuerde el contrato — lo verifica automáticamente, antes de ejecutar una sola línea. Fuente: Pexels (licencia libre).
Ficha de referencia rápida del módulo typing y mypy en Python: Optional, Union, TypeVar, Protocol, dataclass, TypedDict y comandos mypy
La referencia completa del sistema de tipos de Python en una página: tipos básicos y colecciones, Optional/Union/Literal, TypeVar y Generic, Protocol para duck typing estático, dataclass y TypedDict, y los comandos y opciones principales de mypy. Ficha de referencia: Ciberaula.

❓ Preguntas frecuentes

❓ Preguntas frecuentes sobre Type hints y mypy en Python: tipado estático opcional

Las dudas más comunes respondidas de forma clara y directa.

No. Los type hints son anotaciones puramente informativas para el intérprete Python: en tiempo de ejecución son ignorados completamente y no afectan al comportamiento del programa. Puedes anotar una variable como int y asignarle un string sin que Python lance ningún error. La comprobación de tipos ocurre en tiempo de análisis estático, con herramientas externas como mypy, pyright o los type checkers integrados en IDEs (PyCharm, VS Code con Pylance). Esta separación es deliberada: Python sigue siendo dinámico, pero el ecosistema de herramientas puede detectar errores de tipos antes de ejecutar el código. La excepción son las librerías que leen las anotaciones en tiempo de ejecución explícitamente: Pydantic, FastAPI y attrs usan typing.get_type_hints() para validar y serializar datos, pero eso es específico de esas librerías, no del intérprete.
Son equivalentes semánticamente: Optional[X] es exactamente Union[X, None], y X | None (sintaxis de Python 3.10+) produce el mismo tipo. La elección es de estilo y compatibilidad. Usa Optional[X] si tu proyecto soporta Python < 3.10 o si prefieres la legibilidad explícita de "este argumento es opcional". Usa X | None (o X | Y para cualquier union) si estás en Python 3.10+ y prefieres la sintaxis más concisa y natural. La convención actual en proyectos modernos es la sintaxis de barra vertical porque es más legible y consistente con otros lenguajes: str | None es más claro que Optional[str] para un lector que no conoce el alias. Para parámetros de función con valor por defecto None, siempre anota el tipo como Optional[X] o X | None — nunca solo X, aunque en la práctica None se acepte.
Ambos definen interfaces, pero con filosofías opuestas. ABC usa herencia nominal: una clase debe heredar explícitamente de la ABC para considerarse un subtipo, aunque implemente todos los métodos. Protocol usa tipado estructural (duck typing estático): cualquier clase que implemente los métodos definidos en el Protocol se considera compatible, sin necesidad de herencia. La diferencia práctica: con ABC, si quieres que una clase de terceros (que no puedes modificar) sea compatible con tu interfaz, no puedes hacerlo sin un wrapper. Con Protocol, si esa clase de terceros ya tiene los métodos necesarios, es automáticamente compatible. Protocol es la forma moderna y recomendada cuando quieres definir interfaces sin acoplamiento fuerte. ABC sigue siendo útil cuando quieres garantizar herencia explícita (por ejemplo, para usar super() en la implementación) o cuando trabajas con el sistema de registro virtual de ABCs.
mypy en modo estricto puede ser frustrante al principio, especialmente en código legado sin anotaciones. La estrategia recomendada es incremental: empieza con mypy --ignore-missing-imports en modo permisivo, anota solo los módulos más críticos, y activa opciones estrictas gradualmente (--disallow-untyped-defs, --strict). En proyectos nuevos, añadir tipos desde el principio tiene coste muy bajo y retorno alto — el autocompletado mejora drásticamente, los errores de tipos se detectan antes del CI, y el código se documenta solo. En proyectos existentes grandes (100k+ líneas sin tipos), la migración completa puede llevar semanas. La alternativa pragmática es adoptar "tipado progresivo": anotar solo las interfaces públicas de módulos y funciones críticas, e ignorar los internos con # type: ignore selectivamente. Para equipos, pyright (de Microsoft, integrado en VS Code) es a menudo más rápido que mypy y tiene mejor inferencia de tipos.
TypeVar es una variable de tipo: un placeholder que representa un tipo concreto que se resuelve en tiempo de análisis estático según el uso. Lo necesitas cuando quieres escribir funciones o clases genéricas que funcionan con múltiples tipos pero manteniendo la relación de tipos. El ejemplo canónico: def primero(lista: list[T]) -> T — mypy entiende que si pasas list[int], el retorno es int; si pasas list[str], el retorno es str. Sin TypeVar tendrías que usar list[Any] y perder toda la información de tipo. Los casos de uso prácticos son: funciones de transformación (map, filter, reduce), clases contenedor (Pila[T], Cola[T]), decoradores que preservan la firma de la función decorada. Python 3.12 introduce una sintaxis simplificada para generics: def primero[T](lista: list[T]) -> T sin necesidad de declarar TypeVar explícitamente, lo que reduce el boilerplate considerablemente.
Valora este artículo

💬 Foro de discusión

¿Tienes dudas sobre Type hints y mypy en Python: tipado estático opcional? Comparte tu pregunta con la comunidad.

¿Tienes cuenta? o comenta como invitado ↓

Todavía no hay mensajes. ¡Sé el primero en participar!