pythontype-hintingpylancesubtypestructural-typing

How to provide type hint for a function that returns an Protocol subclass in Python?


If a function returns a subclass of a Protocol, what is the recommended type-hint for the return type of that function?

The following is a simplified piece of code for representation

from typing import Protocol, Type
from abc import abstractmethod

class Template(Protocol):
    @abstractmethod
    def display(self) -> None:
        ...
        
class Concrete1(Template):
    def __init__(self, grade: int) -> None:
        self._grade = grade
        
    def display(self) -> None:
        print(f"Displaying {self._grade}")
        
class Concrete2(Template):
    def __init__(self, name: str) -> None:
        self._name = name
        
    def display(self) -> None:
        print(f"Printing {self._name}")
        
        
def give_class(type: int) -> Type[Template]:
    if type == 1:
        return Concrete1
    else:
        return Concrete2
    
    
concrete_class =  give_class(1)
concrete_class(5)

In the line concrete_class(5), Pylance informs Expected no arguments to "Template" constructor.


Solution

  • Protocols are not ABCs

    Let me start of by emphasizing that protocols were introduced specifically so you do not have to define a nominal subclass to create a subtype relation. That is why it is called structural subtyping. To quote PEP 544, the goal was

    allowing users to write [...] code without explicit base classes in the class definition.

    While you can subclass a protocol explicitly when defining a concrete class, that is not what they were designed for.

    Protocols are not abstract base classes. By using your protocol like an ABC, you are basically discarding everything that makes a protocol useful in the first place.


    Your error and possible solutions

    As to why you are getting that error, that is easily explained. Your Template protocol does not define its own __init__ method. When a variable is declared to be of type type[Template] (i.e. a class implementing the Template protocol) and you want to instantiate it, the type checker will see that Template does not define an __init__ and fall back to the object.__init__, which takes no arguments. Thus, providing an argument to the constructor is correctly marked as an error.

    Since you want to use your protocol not only to annotate pure instances that follow it, but also classes that you want to instantiate (i.e. type[Template]), you need to think about the __init__ method. If you want to express that for a class to implement your Template protocol it can have any constructor whatsoever, you should include such a permissive __init__ signature in the protocol, for example:

    class Template(Protocol):
        def __init__(self, *args: Any, **kwargs: Any) -> None: ...
    

    If you want to be more specific/restrictive, that is possible of course. You could for example declare that Template-compliant classes must take exactly one argument in their __init__, but it can be of any type:

    class Template(Protocol):
        def __init__(self, _arg: Any) -> None: ...
    

    Both of these solutions would work in your example. The latter however would restrict you from passing a keyword-argument to the constructor with any name other than _arg obviously.


    Proper structural subtyping

    To conclude, I would suggest you actually utilize the power of protocols properly to allow for structural subtyping and get rid of the explicit subclassing and abstractmethod decorators. If all you care about is a fairly general constructor and your display method, you can achieve that like this:

    from typing import Any, Protocol
    
    
    class Template(Protocol):
        def __init__(self, _arg: Any) -> None: ...
    
        def display(self) -> None: ...
    
    
    class Concrete1:
        def __init__(self, grade: int) -> None:
            self._grade = grade
    
        def display(self) -> None:
            print(f"Displaying {self._grade}")
    
    
    class Concrete2:
        def __init__(self, name: str) -> None:
            self._name = name
    
        def display(self) -> None:
            print(f"Printing {self._name}")
    
    
    def give_class(type_: int) -> type[Template]:
        if type_ == 1:
            return Concrete1
        else:
            return Concrete2
    
    
    concrete_class = give_class(1)
    concrete_class(5)
    

    This passes mypy --strict without errors (and should satisfy Pyright too). As you can see, both Concrete1 and Concrete2 are accepted as return values for give_class because they both follow the Template protocol.


    Proper use of ABCs

    There are of course still valid applications for abstract base classes. For example, if you wanted to define an actual implementation of a method in your base class that itself calls an abstract method, subclassing that explicitly (nominal subtyping) can make perfect sense.

    Example:

    from abc import ABC, abstractmethod
    from typing import Any
    
    
    class Template(ABC):
        @abstractmethod
        def __init__(self, _arg: Any) -> None: ...
    
        @abstractmethod
        def display(self) -> None: ...
    
        def call_display(self) -> None:
            self.display()
    
    
    class Concrete1(Template):
        def __init__(self, grade: int) -> None:
            self._grade = grade
    
        def display(self) -> None:
            print(f"Displaying {self._grade}")
    
    
    class Concrete2(Template):
        def __init__(self, name: str) -> None:
            self._name = name
    
        def display(self) -> None:
            print(f"Printing {self._name}")
    
    
    def give_class(type_: int) -> type[Template]:
        if type_ == 1:
            return Concrete1
        else:
            return Concrete2
    
    
    concrete_class = give_class(1)
    obj = concrete_class(5)
    obj.call_display()  # Displaying 5
    

    But that is a totally different use case. Here we have the benefit that Concrete1 and Concrete2 are nominal subclasses of Template, thus inherit call_display from it. Since they are nominal subclasses anyway, there is no need for Template to be a protocol.

    And all this is not to say that it is impossible to find applications, where it is useful for something to be both a protocol and an abstract base class. But such a use case should be properly justified and from the context of your question I really do not see any justification for it.