Una función Python puede aceptar varias combinaciones de argumentos y devolver tipos diferentes según la llamada. Una sola anotación con muchas uniones suele perder la relación entre entradas y salidas. typing.overload permite declarar varias firmas estáticas para una única implementación de runtime, mejorando inferencia y autocompletado sin duplicar la lógica.
Esta guía explica declaraciones overload, compatibilidad de implementación, Literal, None, genéricos, parámetros keyword-only, orden, solapamientos inseguros, métodos, Protocol, stubs y pruebas estáticas con mypy o pyright.
El problema de una unión amplia
def buscar(clave: int | str) -> int | str:
if isinstance(clave, int):
return clave * 10
return clave.upper()
resultado = buscar(2)
# el analizador puede ver int | strEn runtime, un int produce int y un str produce str. La anotación simple no conserva esa relación.
Primer overload
from typing import overload
@overload
def buscar(clave: int) -> int: ...
@overload
def buscar(clave: str) -> str: ...
def buscar(clave: int | str) -> int | str:
if isinstance(clave, int):
return clave * 10
return clave.upper()Las funciones decoradas son declaraciones para el analizador. La última función sin decorador es la implementación ejecutada.
Las declaraciones no contienen lógica de runtime
Usa ... o un cuerpo vacío. No pongas comportamiento real en las variantes porque los consumidores no ejecutan esos cuerpos.
@overload
def convertir(valor: bytes) -> str: ...La implementación debe aparecer después de todas las variantes en el mismo bloque.
Compatibilidad de la implementación
La implementación debe aceptar todas las llamadas descritas y devolver todos los tipos prometidos.
@overload
def convertir(valor: int) -> str: ...
@overload
def convertir(valor: bytes) -> str: ...
def convertir(valor: int | bytes) -> str:
if isinstance(valor, bytes):
return valor.decode()
return str(valor)Si una variante acepta bytes, la implementación no puede aceptar solo int. Si promete str, la ruta correspondiente no puede devolver None.
Overload con Literal
Literal es una de las combinaciones más útiles porque el resultado depende de una opción exacta.
from typing import Literal, overload
@overload
def cargar(ruta: str, *, binario: Literal[False] = False) -> str: ...
@overload
def cargar(ruta: str, *, binario: Literal[True]) -> bytes: ...
def cargar(ruta: str, *, binario: bool = False) -> str | bytes:
modo = "rb" if binario else "r"
with open(ruta, modo) as archivo:
return archivo.read()Con binario=True se obtienen bytes; con False o sin argumento se obtiene str.
Un bool no literal
opcion: bool = leer_configuracion()
resultado = cargar("datos.txt", binario=opcion)La opción puede ser verdadera o falsa, por lo que el resultado correcto es str | bytes. Algunas APIs añaden una variante bool general después de las variantes Literal.
Orden de variantes
Coloca las variantes específicas antes de las generales. Una variante amplia al principio puede hacer inalcanzables las siguientes.
@overload
def analizar(valor: Literal["auto"]) -> ConfiguracionAutomatica: ...
@overload
def analizar(valor: str) -> Configuracion: ...Literal["auto"] es más específico que str.
Variantes solapadas
Dos variantes pueden aceptar la misma llamada. Si sus retornos son incompatibles, la API queda ambigua.
@overload
def ejemplo(valor: int) -> int: ...
@overload
def ejemplo(valor: object) -> str: ...Un int también es object. Ambas variantes coinciden, pero los retornos difieren. El solapamiento debe ser intencional y seguro. Prefiere retornos compatibles o rediseña la interfaz.
bool es subtipo de int
@overload
def formatear(valor: bool) -> str: ...
@overload
def formatear(valor: int) -> bytes: ...Coloca bool primero. La implementación también debe comprobar bool antes de int si el comportamiento es distinto.
Overload con None
@overload
def normalizar(valor: None) -> None: ...
@overload
def normalizar(valor: str) -> str: ...
def normalizar(valor: str | None) -> str | None:
if valor is None:
return None
return valor.strip().casefold()El analizador conserva None para entrada None y str para entrada string.
Defaults y argumentos omitidos
Las APIs que distinguen argumento omitido de None explícito suelen necesitar una sentinela privada.
from typing import TypeVar
T = TypeVar("T")
_AUSENTE = object()
@overload
def leer_opcion(nombre: str) -> str: ...
@overload
def leer_opcion(nombre: str, default: T) -> str | T: ...
def leer_opcion(nombre: str, default: object = _AUSENTE) -> object:
if nombre in configuracion:
return configuracion[nombre]
if default is _AUSENTE:
raise KeyError(nombre)
return defaultLa sentinela evita confundir “no proporcionado” con “proporcionado como None”.
Overload genérico
from collections.abc import Iterable
from typing import TypeVar
T = TypeVar("T")
@overload
def primero(valores: tuple[T, ...]) -> T: ...
@overload
def primero(valores: list[T]) -> T: ...
def primero(valores: Iterable[T]) -> T:
return next(iter(valores))Quizá una sola firma genérica con Iterable sea suficiente. No uses overload cuando TypeVar expresa la relación de forma más simple.
Cuándo TypeVar reemplaza overload
T = TypeVar("T")
def identidad(valor: T) -> T:
return valorNo hace falta una variante para int, str, bytes y cada tipo futuro. Overload es para formas de llamada realmente distintas.
Conservar tipos de secuencia
@overload
def cortar(datos: str, inicio: int, fin: int) -> str: ...
@overload
def cortar(datos: bytes, inicio: int, fin: int) -> bytes: ...
def cortar(datos: str | bytes, inicio: int, fin: int) -> str | bytes:
return datos[inicio:fin]Un TypeVar restringido también puede expresar esta relación. Elige la forma más clara y compatible.
Parámetros keyword-only
@overload
def consultar(id_: int, *, completo: Literal[False] = False) -> Resumen: ...
@overload
def consultar(id_: int, *, completo: Literal[True]) -> RegistroCompleto: ...Las variantes deben conservar la naturaleza keyword-only y la implementación necesita una interfaz compatible.
Los nombres de parámetros importan
Los nombres forman parte de llamadas por keyword. Mantenlos consistentes.
@overload
def abrir(origen: str) -> Recurso: ...
@overload
def abrir(origen: Path) -> Recurso: ...Usar nombres distintos para la misma posición confunde y puede generar errores.
Métodos sobrecargados
class Cache:
@overload
def obtener(self, clave: str) -> object: ...
@overload
def obtener(self, clave: str, default: T) -> object | T: ...
def obtener(self, clave: str, default: object = _AUSENTE) -> object:
...self aparece en todas las variantes. Para classmethods y staticmethods, aplica los decoradores de forma consistente según el analizador.
Overload y Self
Factories pueden usar overloads cuando opciones diferentes producen tipos distintos. Si un método simplemente devuelve la clase concreta actual, typing.Self suele ser más simple. Consulta typing.Self en Python.
Overload en Protocol
from typing import Protocol
class Parser(Protocol):
@overload
def parse(self, datos: str) -> Documento: ...
@overload
def parse(self, datos: bytes) -> DocumentoBinario: ...Una implementación estructural debe ser compatible con el conjunto completo.
Overload en stubs
Los archivos .pyi no contienen la implementación Python normal. Las variantes pueden representar toda la interfaz pública de una función implementada en C, generada dinámicamente o difícil de anotar.
Comportamiento en runtime
Las variantes no son llamadas normalmente. La implementación final reemplaza el nombre. Versiones modernas ofrecen introspección como get_overloads(), principalmente para herramientas.
from typing import get_overloads
variantes = get_overloads(cargar)No bases la lógica principal en ese registro; overload sigue siendo una construcción estática.
Overload frente a singledispatch
functools.singledispatch selecciona implementaciones en runtime según el tipo del primer argumento. overload solo describe firmas al analizador. Resuelven problemas distintos.
Overload no implementa despacho
@overload
def procesar(valor: int) -> int: ...
@overload
def procesar(valor: str) -> str: ...Sin una implementación posterior no existe una función útil. El decorador no elige automáticamente una variante.
Decoradores y overload
Un decorador aplicado a una API sobrecargada debe conservar todas las firmas. ParamSpec ayuda en decoradores genéricos; APIs complejas pueden necesitar overloads explícitos. Consulta ParamSpec en Python.
Retorno dependiente de dos argumentos
@overload
def combinar(a: str, b: str) -> str: ...
@overload
def combinar(a: bytes, b: bytes) -> bytes: ...
def combinar(a: str | bytes, b: str | bytes) -> str | bytes:
if type(a) is not type(b):
raise TypeError("tipos incompatibles")
return a + bLa implementación acepta una unión amplia que incluye combinaciones inválidas y las rechaza en runtime. Es común siempre que todas las llamadas declaradas sean válidas.
Documentación
Las herramientas pueden mostrar la implementación o las variantes. Escribe una docstring que explique la relación entre argumentos y retornos, errores, efectos secundarios y validación.
Demasiadas variantes
Grandes conjuntos aumentan mantenimiento y coste del analizador. Si necesitas decenas de combinaciones, considera objetos de configuración, métodos separados, builders, genéricos o una API más explícita.
Errores comunes
- Olvidar la implementación: overload no crea despacho.
- Poner lógica en las variantes: los consumidores ejecutan la implementación final.
- Usar una implementación incompatible: algunas llamadas prometidas no funcionan.
- Colocar la variante general primero: las específicas pueden no coincidir.
- Crear solapamientos inseguros: la inferencia queda ambigua.
- Usar overload donde basta TypeVar: la API se vuelve repetitiva.
Ejemplo completo: deserialización por formato
from dataclasses import dataclass
from typing import Literal, overload
import json
@dataclass
class Configuracion:
nombre: str
activo: bool
@overload
def deserializar(
datos: str,
*,
formato: Literal["json"],
) -> dict[str, object]: ...
@overload
def deserializar(
datos: bytes,
*,
formato: Literal["binario"],
) -> Configuracion: ...
def deserializar(
datos: str | bytes,
*,
formato: Literal["json", "binario"],
) -> dict[str, object] | Configuracion:
if formato == "json":
if not isinstance(datos, str):
raise TypeError("json exige str")
resultado = json.loads(datos)
if not isinstance(resultado, dict):
raise ValueError("se esperaba objeto JSON")
return resultado
if not isinstance(datos, bytes):
raise TypeError("binario exige bytes")
nombre, activo = decodificar_registro(datos)
return Configuracion(nombre=nombre, activo=activo)Las variantes exponen solo combinaciones válidas y retornos precisos. La implementación valida en runtime porque el código no tipado puede saltarse el análisis.
Probar overloads
texto = cargar("a.txt")
reveal_type(texto) # str
binario = cargar("a.bin", binario=True)
reveal_type(binario) # bytesMantén fixtures con llamadas válidas e inválidas y ejecuta mypy o pyright en CI. Las pruebas de runtime deben cubrir la implementación y combinaciones dinámicas inválidas.
Buenas prácticas
Empieza por el contrato del consumidor. Usa pocas variantes específicas. Ordénalas de más específica a más general. Mantén nombres y defaults consistentes. Asegura que la implementación cubra todas. Prefiere TypeVar, Protocol, ParamSpec o métodos separados cuando sean más simples.
Conclusión
typing.overload describe varias firmas estáticas para una implementación y conserva relaciones precisas entre argumentos y retornos. Es especialmente útil con Literal, None, formatos alternativos, defaults y APIs de compatibilidad.
La documentación oficial de overload en Python define las reglas. Úsalo para llamadas realmente distintas, no como sustituto de un diseño simple o de validación en runtime.







