Narrow down type-hint of return value to child class if function only hints to parent class

69 views Asked by At

I have an external function that works like a factory and returns instances of different classes with a common parent type: e.g.

class Printer:
    def make(self, blueprint:str) -> Blueprint: ...

class PrintA(Blueprint): 
     def a_specific(self) -> int: ...

class PrintB(Blueprint):
     def b_specific(self, args):
         """docstring"""

a : PrintA = Printer().make("A")
b : PrintB = Printer().make("B")
# TypeHint: "(variable) a: Blueprint"
# TypeHint: "(variable) b: Blueprint"

# Usage:
result = a.a_specific() # function & type of result not detected : Any
b.b_specific(arg) # function not detected, parameters and description unknown : Any

The problem is that a and b have Blueprint as their given type hint. How can I force the correct type hint onto the variables, so that the child functions can be detected by my IDE?


I tried to use # type: ignore[override] but that does not seem applicable for this purpose as its no error; is there by chance another magic comment command to indicate that the type checker should trust the human annotation?


Notes: I cannot adjust Printer or the Blueprints itself. Modify a matching *.pyi file is possible but should be minimal. The amount of Blueprints is large.

Not sure if relevant but using VS-Code and Python Extension for type hinting.

At least in my example PrintA and PrintB cannot be instantiated directly via Python and Printer has to be used to initialize them.

3

There are 3 answers

1
Riccardo Bucco On BEST ANSWER

A possible method that helps static type checkers is type casting using typing.cast:

from typing import cast

a = cast(PrintA, Printer().make("A"))
b = cast(PrintB, Printer().make("B"))

The IDE and static type checkers should now recognize a and b correctly.

Unlike assertions, there is no runtime check to ensure the cast is accurate, so be careful.

0
chepner On

If you can at least change the type hints for Printer in a pyi file, you can overload Printer.make to have argument-specific return types.

from typing import overload, Literal


class Printer:

    @overload
    def make(self, blueprint: Literal["A"]) -> PrintA:
        ...

    @overload
    def make(self, blueprint: Literal["B"]) -> PrintB:
        ...

    # etc

But this requires the argument to make be an appropriate literal; you can't use, for example, arbitrary user input that happens to have an expected value at runtime.

Now the following should work

a = Printer().make("A")  # reveal_type(a) == PrintA
x: Literal["B"] = "B"
b = Printer().make(x)  # reveal_type(b) == PrintB

For non-literal arguments, you'll need some redundant-looking code in order to pass static type-checking. For example, a match statement could be used to narrow the static type of a dynamically generated string x:

x = input()  # reveal_type(x) is str
match x:
    case "A":
        # reveal_type(x) is Literal['A']
        Printer().make(x).a_specific()
    case "B":
        # reveal_type(x) is Literal['B']
        Printer().make(x).b_specific()
    case _:
        raise ValueError(f"No known blueprint type {x}")

Something like

x = input()  # say "A" is entered
# magic to infer static type of PrintA for c???
c = Printer().make(x)

is right out, because you can't statically determine the value of c. Note the match statement above only uses the value of Printer().make(x) within the context where the static type of x is known. You can't assign to c in each branch and expect the static type of c to be known outside the match statement.

0
chepner On

If you are willing to stop using Printer.make directly, I suspect you could use a properly generic wrapper instead. Something like

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

def printer_make(p: Printer, blueprint: T) -> T:
    return p.make({ PrintA: "A", PrintB: "B", ...})


p = Printer()
a = printer_make(p, PrintA)  # reveal_type(a) == PrintA