Skip to content

Fix: Python Protocol Not Working — Type Checker Rejects Compatible Class, runtime_checkable Fails, or Protocol Not Recognized

FixDevs ·

Quick Answer

How to fix Python Protocol class issues — structural subtyping vs nominal typing, runtime_checkable, Protocol inheritance, TypeVar constraints, and common mypy/pyright errors with Protocol.

The Problem

A class that implements all the required methods is rejected by the type checker:

from typing import Protocol

class Drawable(Protocol):
    def draw(self) -> None: ...

class Circle:
    def draw(self) -> None:
        print("Drawing circle")

def render(shape: Drawable) -> None:
    shape.draw()

render(Circle())  # mypy error: Argument 1 has incompatible type "Circle"

Or isinstance() raises a TypeError at runtime:

from typing import Protocol

class Serializable(Protocol):
    def to_json(self) -> str: ...

obj = MyObject()
isinstance(obj, Serializable)  # TypeError: Protocols with non-method members don't support issubclass

Or a Protocol with a class variable isn’t matched:

class HasName(Protocol):
    name: str  # Class variable requirement

class Person:
    def __init__(self, name: str):
        self.name = name  # Instance attribute

obj: HasName = Person("Alice")  # Works in some type checkers, rejected in others

Why This Happens

Python’s Protocol implements structural subtyping (duck typing with type checking), but has specific rules:

  • Structural != nominal — unlike abstract base classes, you don’t need to inherit from a Protocol. A class automatically satisfies a Protocol if it has the required attributes and methods with compatible signatures. But signatures must match exactly — return types, parameter types, and names all matter.
  • isinstance() requires @runtime_checkable — by default, Protocols are only for static type checking. To use them with isinstance(), you must decorate the Protocol with @runtime_checkable. Even then, runtime checks only verify the presence of attributes/methods, not their signatures.
  • Class variables vs instance attributes — a Protocol that declares name: str requires that the class or instance has a name attribute of type str. Instance attributes defined in __init__ do satisfy this, but type checkers differ in how strictly they enforce this.
  • Method signatures must be compatible — if the Protocol requires def process(self, value: int) -> str, a class method def process(self, value: float) -> str may or may not satisfy it depending on variance rules.

Fix 1: Define Protocols Correctly

Protocol methods use ... or pass as the body — they define the interface, not the implementation:

from typing import Protocol

# WRONG — Protocol with actual implementation (should use ABC instead)
class Drawable(Protocol):
    def draw(self) -> None:
        print("default draw")  # This becomes an implementation, not just a signature

# CORRECT — Protocol with ellipsis bodies
class Drawable(Protocol):
    def draw(self) -> None: ...
    def resize(self, factor: float) -> None: ...

# CORRECT — Protocol with properties
class HasArea(Protocol):
    @property
    def area(self) -> float: ...

    @property
    def perimeter(self) -> float: ...

# CORRECT — Protocol with class methods
class Factory(Protocol):
    @classmethod
    def create(cls, data: dict) -> 'Factory': ...

Implementing a Protocol — no inheritance required:

class Circle:
    def __init__(self, radius: float):
        self.radius = radius

    def draw(self) -> None:
        print(f"Drawing circle with radius {self.radius}")

    def resize(self, factor: float) -> None:
        self.radius *= factor

# Circle implicitly satisfies Drawable — no need to inherit from it
def render(shape: Drawable) -> None:
    shape.draw()

render(Circle(5.0))  # OK — Circle has .draw() and .resize()

Fix 2: Enable runtime_checkable for isinstance Checks

If you need isinstance() checks at runtime, decorate the Protocol:

from typing import Protocol, runtime_checkable

# WRONG — no decorator, isinstance() raises TypeError
class Serializable(Protocol):
    def to_json(self) -> str: ...

isinstance({}, Serializable)  # TypeError!

# CORRECT — add @runtime_checkable
@runtime_checkable
class Serializable(Protocol):
    def to_json(self) -> str: ...

class JsonDocument:
    def to_json(self) -> str:
        return '{"type": "document"}'

isinstance(JsonDocument(), Serializable)  # True
isinstance({}, Serializable)              # False — dict has no to_json method

Warning: @runtime_checkable only checks for the presence of attributes and methods — not their type signatures. isinstance(obj, Serializable) returns True if obj has a to_json attribute, even if it’s not callable or returns the wrong type.

Practical runtime_checkable pattern:

@runtime_checkable
class Closeable(Protocol):
    def close(self) -> None: ...

def safe_close(resource: object) -> None:
    if isinstance(resource, Closeable):
        resource.close()
    # No error if resource doesn't support close

Fix 3: Fix Signature Mismatch Errors

Protocol method signatures must be compatible with the implementing class:

from typing import Protocol

class Processor(Protocol):
    def process(self, data: str) -> int: ...

# WRONG — parameter name mismatch (mypy is lenient about this, pyright is strict)
class MyProcessor:
    def process(self, input: str) -> int:  # 'input' vs 'data'
        return len(input)

# WRONG — return type incompatible
class BadProcessor:
    def process(self, data: str) -> float:  # float is not int
        return len(data) * 1.5

# WRONG — extra required parameter
class AlsoWrong:
    def process(self, data: str, encoding: str) -> int:  # extra required param
        return len(data)

# CORRECT — extra optional parameter is OK
class GoodProcessor:
    def process(self, data: str, encoding: str = 'utf-8') -> int:
        return len(data.encode(encoding))

Use Protocol with TypeVar for generic protocols:

from typing import Protocol, TypeVar

T = TypeVar('T')

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

class Score:
    def __init__(self, value: int):
        self.value = value

    def __lt__(self, other: 'Score') -> bool:
        return self.value < other.value

    def __le__(self, other: 'Score') -> bool:
        return self.value <= other.value

def find_minimum(items: list[Comparable]) -> Comparable:
    return min(items)

Fix 4: Protocol Inheritance and Composition

Combine Protocols to build composite interfaces:

from typing import Protocol

class Readable(Protocol):
    def read(self, n: int = -1) -> bytes: ...

class Writable(Protocol):
    def write(self, data: bytes) -> int: ...

class Seekable(Protocol):
    def seek(self, pos: int) -> int: ...
    def tell(self) -> int: ...

# Compose protocols
class ReadWritable(Readable, Writable, Protocol): ...

class BinaryFile(Readable, Writable, Seekable, Protocol): ...

# A class satisfying BinaryFile must implement all methods from all three protocols
import io
def process_file(f: BinaryFile) -> None:
    data = f.read(1024)
    f.seek(0)
    f.write(data)

# io.BytesIO satisfies BinaryFile
process_file(io.BytesIO(b"test data"))  # OK

Extend Protocol with abstract behavior:

from typing import Protocol
from abc import abstractmethod

class Repository(Protocol):
    @abstractmethod
    def find_by_id(self, id: int): ...

    @abstractmethod
    def save(self, entity): ...

    def find_all(self):
        # Protocol CAN have default implementations
        # Subclasses can override or use this default
        raise NotImplementedError

Fix 5: Protocols with Class Variables and Properties

Declaring class-level variables and properties in a Protocol:

from typing import Protocol, ClassVar

class Configuration(Protocol):
    # Instance attribute — implementing class must have this as instance or class attr
    debug: bool

    # Class variable — must be on the class, not the instance
    VERSION: ClassVar[str]

    # Property
    @property
    def name(self) -> str: ...

class AppConfig:
    VERSION = "1.0.0"  # Class variable ✓

    def __init__(self, debug: bool = False):
        self.debug = debug  # Instance attribute ✓

    @property
    def name(self) -> str:  # Property ✓
        return "MyApp"

# AppConfig satisfies Configuration
config: Configuration = AppConfig()  # OK

Dataclass as Protocol implementation:

from dataclasses import dataclass
from typing import Protocol

class Point2D(Protocol):
    x: float
    y: float

@dataclass
class CartesianPoint:
    x: float
    y: float

@dataclass
class PolarPoint:
    r: float
    theta: float

    @property
    def x(self) -> float:  # Property satisfies 'x: float' in Protocol
        import math
        return self.r * math.cos(self.theta)

    @property
    def y(self) -> float:
        import math
        return self.r * math.sin(self.theta)

def distance_from_origin(p: Point2D) -> float:
    return (p.x ** 2 + p.y ** 2) ** 0.5

distance_from_origin(CartesianPoint(3.0, 4.0))  # 5.0
distance_from_origin(PolarPoint(5.0, 0.927))    # ~5.0

Fix 6: Explicit Protocol Implementation with Typing

When you want to be explicit that a class implements a Protocol (useful for documentation and early error detection):

from typing import Protocol

class Drawable(Protocol):
    def draw(self) -> None: ...

# Option 1: Inherit from Protocol (makes it explicit but class is now a Protocol itself)
class Circle(Drawable, Protocol):  # This makes Circle a Protocol, not an implementation!
    ...  # DON'T do this for concrete classes

# Option 2: Use runtime_checkable + assert in tests
@runtime_checkable
class Drawable(Protocol):
    def draw(self) -> None: ...

class Circle:
    def draw(self) -> None:
        print("Drawing circle")

# In tests — verify implementation at development time
assert isinstance(Circle(), Drawable), "Circle must implement Drawable"

# Option 3: TYPE_CHECKING guard for explicit annotation
from typing import TYPE_CHECKING
if TYPE_CHECKING:
    from typing import assert_type
    c = Circle()
    assert_type(c, Drawable)  # Type checker error if Circle doesn't satisfy Drawable

# Option 4: Python 3.12+ — @override for method implementations
from typing import override

class Circle:
    @override  # Explicit, but only works with class inheritance — not Protocols
    def draw(self) -> None:
        print("Drawing circle")

Protocol with __init__ — use a factory Protocol instead:

# Protocols can't constrain __init__ signatures meaningfully
# Use a factory Protocol instead:

class WidgetFactory(Protocol):
    def __call__(self, width: int, height: int) -> 'Widget': ...

class ButtonFactory:
    def __call__(self, width: int, height: int) -> 'Widget':
        return Button(width, height)

def create_widget(factory: WidgetFactory, w: int, h: int) -> 'Widget':
    return factory(w, h)

Still Not Working?

mypy and pyright disagree on Protocol compatibility — mypy and pyright implement Protocol checking with slightly different strictness. If one passes and the other fails, check: (1) property vs attribute compatibility, (2) contravariance/covariance in method parameters, and (3) whether you have strict = true in your config. pyright is generally stricter.

Protocol check fails for __dunder__ methods — some special methods require exact signatures. For example, __len__ must return int, not float. Check the exact signature required by the Protocol and match it precisely.

Callable Protocol for function-like objects — if you need to type a callable with a specific signature, use Callable from typing or a Protocol with __call__:

from typing import Callable, Protocol

# Using Callable — simpler for plain functions
Handler = Callable[[str, int], bool]

# Using Protocol — allows for additional attributes on the callable
class HandlerProtocol(Protocol):
    retry_count: int  # Callable with extra attributes

    def __call__(self, event: str, code: int) -> bool: ...

TypeVar bound to Protocol for generic functions:

from typing import TypeVar, Protocol

class Drawable(Protocol):
    def draw(self) -> None: ...

T = TypeVar('T', bound=Drawable)

def draw_twice(shape: T) -> T:
    shape.draw()
    shape.draw()
    return shape  # Returns the same type as input, not just Drawable

circle: Circle = draw_twice(Circle())  # Returns Circle, not Drawable

For related Python type hint issues, see Fix: Python mypy Type Error and Fix: Python Decorator Not Working.

F

FixDevs

Solo developer based in Japan. Every solution is cross-referenced with official documentation and tested before publishing.

Was this article helpful?

Related Articles