pythoninheritancepython-typinginner-classes

Type hinting an instance of a nested class


I have a class structure that looks something like this:

class Base:
    class Nested:
        pass

    def __init__(self):
        self.nestedInstance = self.Nested()

where subclasses of Base each have their own Nested class extending the original, like this:

class Sub(Base):
    class Nested(Base.Nested):
        pass

This works perfectly, and instances of Sub have their nestedInstance attributes set to instances of Sub.Nested.

However, in my IDE the nestedInstance attribute is always treated as an instance of Base.Nested, not the inherited Sub.Nested. How can I make it so that nestedInstance will be inferred to be Sub.Nested rather than Base.Nested? (Without having to add extra code to every subclass; preferably, this would all be done in Base.)

(By the way, I'm aware that this is an odd structure, and I can go into detail about why I chose it if necessary, but I think it's pretty elegant for my situation, and I'm hoping there's a solution to this problem.)


Solution

  • I don't agree with the statement that you were trying to violate the Liskov substitution principle. You were merely looking for a way to let a static type checker infer the type of nested_instance for classes inheriting from Base to be their respective Nested class. Obviously this wasn't possible with the code you had; otherwise there would be no question.

    There actually is a way to minimize repetition and accomplish what you want.


    Generics to the rescue!

    You can define your Base class as generic over a type variable with the upper bound of Base.Nested. When you define Sub as a subclass Base, you provide a reference to Sub.Nested as the concrete type argument. Here is the setup:

    from typing import Generic, TypeVar, cast
    
    
    N = TypeVar("N", bound="Base.Nested")
    
    
    class Base(Generic[N]):
        nested_instance: N
    
        class Nested:
            pass
    
        def __init__(self) -> None:
            self.nested_instance = cast(N, self.Nested())
    
    
    class Sub(Base["Sub.Nested"]):
        class Nested(Base.Nested):
            pass
    

    This is actually all you need. For more info about generics I recommend the relevant section of PEP 484. A few things to note:

    Why do we need the bound?

    If we were to just use N = TypeVar("N"), the type checker would have no problem if we wanted do define a subclass like this:

    class Broken(Base[int]):
        class Nested(Base.Nested):
            pass
    

    But this would be a problem since now the nested_instance attribute would be expected to be of the type int, which is not what we want. That upper bound on N will prevent this causing mypy to complain:

    error: Type argument "int" of "Base" must be a subtype of "Nested"  [type-var]
    

    Why explicitly declare nested_instance?

    The whole point of making a class generic is to bind some type variable (like N) to it and then indicate that some associated type inside that class is in fact N (or even multiple). We essentially tell the type checker to expect nested_instance to always be of the type N, which must be provided, whenever Base is used to annotate something.

    However, now the type checker will always complain, if we ever omit the type argument for Base and tried an annotation like this: x: Base. Again, mypy would tell us:

    error: Missing type parameters for generic type "Base"  [type-arg]
    

    This may be the only "downside" to the use of generics in this fashion.

    Why cast?

    The problem is that inside Base, the nested_instance attribute is declared as a generic type N, whereas in Base.__init__, we assign an instance of the specific type Base.Nested. Even though it may seem like this should work, it does not. Omitting the cast call results in the following mypy error:

    error: Incompatible types in assignment (expression has type "Nested", variable has type "N")  [assignment]
    

    Are the quotes necessary?

    Yes, and importing __future__.annotations does not help here. I am not entirely sure why that is, but I believe in case of the Base[...] usage the reason is that __class_getitem__ is actually called and you cannot provide Sub.Nested to it because it is not even defined at that point.


    Full working example

    from typing import Generic, TypeVar, cast
    
    
    N = TypeVar("N", bound="Base.Nested")
    
    
    class Base(Generic[N]):
        nested_instance: N
    
        class Nested:
            pass
    
        def __init__(self) -> None:
            self.nested_instance = cast(N, self.Nested())
    
    
    class Sub(Base["Sub.Nested"]):
        class Nested(Base.Nested):
            pass
    
    
    def get_nested(obj: Base[N]) -> N:
        return obj.nested_instance
    
    
    def assert_instance_of_nested(nested_obj: N, cls: type[Base[N]]) -> None:
        assert isinstance(nested_obj, cls.Nested)
    
    
    if __name__ == '__main__':
        sub = Sub()
        nested = get_nested(sub)
        assert_instance_of_nested(nested, Sub)
    

    This script works "as is" and mypy is perfectly happy with it.

    The two functions are just for demonstration purposes, so that you see how you could leverage the generic Base.


    Additional sanity checks

    To assure you even more, you can for example add reveal_type(sub.nested_instance) at the bottom and mypy will tell you:

    note: Revealed type is "[...].Sub.Nested"
    

    This is what we wanted.

    If we declare a new subclass

    class AnotherSub(Base["AnotherSub.Nested"]):
        class Nested(Base.Nested):
            pass
    

    and try this

    a: AnotherSub.Nested
    a = Sub().nested_instance
    

    we are again correctly reprimanded by mypy:

    error: Incompatible types in assignment (expression has type "[...].Sub.Nested", variable has type "[...].AnotherSub.Nested")  [assignment]
    

    Hope this helps.

    PS

    To be clear, you can still inherit from Base without specifying the type argument. This has no runtime implications either way. It's just that a strict type checker will complain about it because it is generic, just as it would complain if you annotate something with list without specifying the type argument. (Yes, list is generic.)

    Also, whether or not your IDE actually infers this correctly of course depends on how consistent their internal type checker is with the typing rules in Python. PyCharm for example seems to deal with this setup as expected.