How to make python typing recognize subclasses as valid types when it expects their parent class?

Viewed 1851

Here is a minimal example of what I need to do:

from typing import Callable, Any


class Data:
    pass


class SpecificData(Data):
    pass


class Event:
    pass


class SpecificEvent(Event):
    pass


def detect_specific_event(data: SpecificData, other_info: str) -> SpecificEvent:
    return SpecificEvent()


def run_detection(callback: Callable[[Data, Any], Event]) -> None:
    return


run_detection(detect_specific_event)

Now I get a warning:

Expected type '(Data, Any) -> Event', got '(data: SpecificData, other_info: str) -> SpecificEvent' instead 

To me it seems like this warning doesn't make sense, as SpecificData and SpecificEvent are subtypes of Data and Event respectively, so everything should be fine. Is there a way to make this work as I expect? My idea is to be able to then have something like:

class OtherSpecificData(Data):
    pass


class OtherSpecificEvent(Event):
    pass


def detect_other_event(data: OtherSpecificData, other_info: str) -> OtherSpecificEvent:
    return OtherSpecificEvent()

run_detection(detect_other_event)

so the run_detection function is as general as possible. Right now this gives the same warning as above.

2 Answers

Parameter sub-typing is opposite direction with return sub-typing.

  • Return value is assigned from callee to caller.
  • Parameter value is assigned from caller to callee.

And assign value should be more specific than variable's expected type. For example:

data: Data = SpecificData()  # okay
data: SpecificData = Data()  # not okay

So you should do:

from typing import Callable, Any


class Data:
    pass


class SpecificData(Data):
    pass


class Event:
    pass


class SpecificEvent(Event):
    pass


def detect_specific_event(data: Data, other_info: str) -> SpecificEvent:
    return SpecificEvent()


def run_detection(callback: Callable[[SpecificData, Any], Event]) -> None:
    return


run_detection(detect_specific_event)

Took me a while to remember which piece of typing to use, but IMHO you want to use cast

Unlike its use in other languages, cast(x,y) doesn't do anything, but it does tell typing consider y as a type x. runtime, it's a no-op, just returns y.

Like compiled languages, if I read it, I would pay special attention to the code: is that really going to work at runtime? are the data types actually going to be correct?:

  • The duplicate closure with the LSP remark is appropriate if you can’t guarantee that whatever is generating data later is only going to hand out SpecificDatas. If you can, then casting would be OK. Your minimal example is missing that bit, but if you had shown what actual data was passing through print(data) then we’d known if LSP applied.
from typing import Callable, Any, cast


class Data:
    pass


class SpecificData(Data):
    pass


class Event:
    pass


class SpecificEvent(Event):
    pass


def detect_specific_event(data: SpecificData, other_info: str) -> SpecificEvent:
    return SpecificEvent()


def run_detection(callback: Callable[[Data, Any], Event]) -> None:
    return


run_detection(cast((Callable[[Data, Any], Event]),detect_specific_event))

Here, you've basically told typing, "accept my word for it" that detect_specific_event is a Callable[[Data, Any], Event]).

outputs of runs and type checks:

$ mypy test2.py
Success: no issues found in 1 source file
$ python test2.py
(venv)$   well your code says nothing.

change cast to the actual sig:

run_detection(cast((Callable[[SpecificData, Any], SpecificEvent]),detect_specific_event))

(venv) $@so.mypy$ mypy test2.py
Argument 1 to "run_detection" has incompatible type "Callable[[SpecificData, Any], SpecificEvent]"; expected "Callable[[Data, Any], Event]"
Found 1 error in 1 file (checked 1 source file)
$ python test2.py 
$ well your code says nothing.
Related