Python usa duck typing. typing.Protocol describe ese contrato para herramientas estáticas sin exigir una clase base común. Complementa los type hints.

from typing import Protocol

class RepositorioUsuario(Protocol):
    def buscar(self, usuario_id: int) -> dict | None:
        ...

def mostrar(repo: RepositorioUsuario, usuario_id: int) -> str:
    usuario = repo.buscar(usuario_id)
    return usuario["nombre"] if usuario else "No encontrado"

Cualquier clase con buscar compatible satisface el protocolo sin herencia explícita. Esto facilita implementaciones en memoria y adaptadores de base de datos.

Describe solo los miembros usados. Protocolos grandes esconden responsabilidades. La especificación oficial explica miembros y subprotocolos.

@runtime_checkable habilita un isinstance limitado, pero no valida firmas completas. Para interpretar anotaciones, consulta la guía de type hints en Python; validar datos externos es otro problema.

Usa Protocol para fronteras pequeñas basadas en comportamiento y ABC cuando importa la relación nominal o implementación compartida. Ejecuta un type checker en CI y prueba el comportamiento real.

La compatibilidad incluye las firmas

No basta con repetir el nombre de un método. El type checker también compara parámetros y retornos. Un repositorio cuyo buscar() recibe texto no satisface un contrato que exige un entero. Las reglas de varianza importan: en términos prácticos, la implementación debe aceptar todo lo que el consumidor pueda enviar y devolver un valor compatible con lo esperado.

El diagnóstico ocurre durante el análisis estático, no al importar el módulo. Pyright, mypy y otras herramientas pueden mostrar mensajes diferentes y ofrecer distintos niveles de rigor. Ejecuta la herramienta elegida sobre todo el proyecto, pues la inferencia y la configuración influyen en el resultado.

Una asignación explícita hace visible la intención:

repositorio: RepositorioUsuario = RepositorioMemoria()

No hace falta repetirla en todas partes. Resulta útil en la composición de la aplicación, donde una implementación concreta se conecta con su consumidor.

Protocolos genéricos

Cuando un comportamiento funciona con varios tipos, usa un protocolo genérico:

from typing import Protocol, TypeVar

T_co = TypeVar("T_co", covariant=True)

class Lector(Protocol[T_co]):
    def leer(self) -> T_co:
        ...

def imprimir_texto(lector: Lector[str]) -> None:
    print(lector.leer())

El sufijo co documenta covarianza: este lector solo produce valores. Un protocolo que consume y produce el mismo tipo suele necesitar un parámetro invariante. No declares varianza solo para silenciar el checker; modela la dirección real de los datos.

Las versiones recientes de Python también ofrecen una sintaxis nueva para parámetros de tipo, pero una biblioteca debe respetar su versión mínima. Mantener compatibilidad con el proyecto suele ser más importante que adoptar inmediatamente la notación más nueva.

Callbacks y métodos asíncronos

Protocol puede describir métodos de clase, métodos estáticos, propiedades de solo lectura y métodos asíncronos. Para callbacks con parámetros complejos, un protocolo con __call__ expresa más que Callable:

class Transformador(Protocol):
    def __call__(
        self,
        valor: str,
        *,
        quitar_espacios: bool = True,
    ) -> str:
        ...

def normalizar(
    texto: str,
    transformar: Transformador,
) -> str:
    return transformar(texto, quitar_espacios=True)

La forma conserva nombres y categorías de parámetros, incluido el argumento exclusivo por palabra clave. También permite declarar atributos adicionales si realmente pertenecen al contrato.

Declara comportamiento asíncrono con async def. No describas un método síncrono como asíncrono solo porque una implementación accede a la red. El consumidor necesita saber si debe usar await.

Componer capacidades pequeñas

Un protocolo puede heredar de otros:

class Legible(Protocol):
    def read(self, tamaño: int = -1) -> bytes:
        ...

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

class RecursoLegible(Legible, Cerrable, Protocol):
    pass

Una función que solo lee debería aceptar Legible, no la combinación completa. Exigir close() sin usarlo añade acoplamiento. Las capacidades separadas producen dobles de prueba pequeños y permiten que tipos existentes satisfagan el contrato.

La herencia explícita de un protocolo está permitida y puede documentar intención. Incluso puede aprovechar implementaciones predeterminadas. La ventaja estructural sigue siendo aceptar clases existentes que nunca importaron el protocolo.

Límites de runtime_checkable

Con @runtime_checkable, isinstance() comprueba la presencia de los atributos requeridos. No ejecuta los métodos ni compara por completo firmas y anotaciones. Un objeto con close = 42 podría superar una comprobación superficial aunque no sea invocable. Además, la inspección puede ser más lenta que una comprobación nominal.

Úsala cuando baste confirmar una capacidad superficial y trata después los errores normales. Para validar JSON, formularios o variables de entorno, utiliza validación de datos. Para exigir pertenencia a una jerarquía en runtime, considera ABC.

Pruebas con una frontera estrecha

Imagina un servicio de recibos:

class Enviador(Protocol):
    def enviar(self, destino: str, mensaje: str) -> None:
        ...

def emitir_recibo(
    enviador: Enviador,
    email: str,
    total: str,
) -> None:
    enviador.enviar(email, f"Total: {total}")

class EnviadorEspia:
    def __init__(self) -> None:
        self.mensajes: list[tuple[str, str]] = []

    def enviar(self, destino: str, mensaje: str) -> None:
        self.mensajes.append((destino, mensaje))

La prueba pasa EnviadorEspia sin herencia ni un mock complejo. El protocolo muestra exactamente qué consume el servicio. Sin embargo, la compatibilidad estática no sustituye pruebas de comportamiento: no garantiza reglas de negocio, tratamiento de fallos o seguridad.

Evita crear un protocolo para cada clase concreta. Aporta valor cuando hay varias implementaciones plausibles, cuando el consumidor necesita solo un subconjunto o cuando una dependencia externa debe sustituirse en pruebas. Para funciones internas sencillas, las anotaciones directas suelen bastar.

Atributos, mutación y propiedades

Un atributo modificable impone un contrato más fuerte que una propiedad de solo lectura. Si el consumidor únicamente consulta el valor, declara @property. Así una implementación puede calcularlo o exponer una propiedad compatible sin prometer asignación. Si el consumidor modifica el atributo, el tipo debe ser seguro tanto al leer como al escribir.

Las variables de clase y de instancia también merecen atención. Anota como parte del protocolo solo lo que el consumidor necesita. Un detalle de configuración interno no debería obligar a todas las implementaciones futuras.

Herencia explícita y errores incompletos

Una clase puede heredar de un protocolo para anunciar intención. Si deja miembros abstractos sin implementar, puede continuar siendo abstracta y fallar al instanciarse. Una implementación puramente estructural no hereda ese comportamiento en runtime; su incompatibilidad aparece en el análisis estático.

No mezcles ambas estrategias sin entender el resultado. La herencia nominal ayuda cuando el equipo quiere una declaración visible. La conformidad estructural es mejor para adaptadores existentes y dependencias de terceros que no deberían conocer tu interfaz.

Evolucionar una interfaz pública

Añadir un miembro obligatorio a un protocolo puede romper todas las implementaciones estructurales aunque ninguna importe el archivo modificado. En una biblioteca pública, trata ese cambio como una modificación de compatibilidad. A veces es mejor crear una capacidad más pequeña y nueva, o permitir una transición versionada.

Tampoco uses un protocolo enorme como inventario de una API externa. Describe la porción consumida por tu módulo. Si otro módulo necesita capacidades distintas, puede declarar su propio protocolo estrecho. Esta práctica evita que una actualización irrelevante del proveedor obligue a cambiar todo el proyecto.

Revisar y probar el contrato

Antes de publicar, localiza cada miembro utilizado por el consumidor y elimina requisitos innecesarios. Comprueba métodos síncronos y asíncronos, parámetros posicionales y exclusivos por palabra clave, retornos opcionales y propiedades de solo lectura.

Ejecuta el checker con al menos dos implementaciones: el adaptador real y un sustituto pequeño de prueba. Después aplica las mismas pruebas de comportamiento a ambos cuando sea razonable. Los tipos describen la forma de las llamadas, pero no garantizan que un repositorio guarde correctamente, que un cliente cierre recursos o que una operación sea idempotente.

Documenta excepciones esperadas, efectos secundarios y reglas de ciclo de vida. Esa información no cabe por completo en una firma, pero forma parte del contrato que los implementadores deben respetar.