Search code examples
pythongenericspython-typing

How to typehint generic function that does dynamic retrieval of generic instance from dictionary?


I have a generic class GenericItem that has a name and a type variable that I want to use as a type hint.

from typing import Generic, TypeVar
from pydantic import BaseModel


T = TypeVar("T", bound=BaseModel)


class GenericItem(Generic[T]):
    def __init__(self, name: str, type_that_i_need_in_typehint: type[T] | None) -> None:
        self.name: str = name
        self.type_: type[T] | None = type_that_i_need_in_typehint

GenericItems are stored in another class: GenericItemStore, that is using a dict to store GenericItem's name/item pairs. It is simplified in the example, the actual class is storing generic Endpoint objects and has httpx functionality under the hood to make API requests.

class GenericItemStore:
    def __init__(self, items: list[GenericItem[T]]) -> None:
        self.generic_items_registry: dict[str, GenericItem[T]] = {}
        self._configure_registry(items)

    def _configure_registry(self, items: list[GenericItem[T]]) -> None:
        for item in items:
            self.generic_items_registry[item.name] = item

    def get_from_registry(self, name: str) -> GenericItem[T]:
        return self.generic_items_registry[name]

    def some_method_that_need_typehints(self, name: str, actual_instance_of_type: T) -> None:
        generic_item: GenericItem[T] = self.get_from_registry(name)
        # there goes other processing
        return None

When I use the GenericItemStore's *some_method_that_need_typehints *I would like to have typehint for a second argument, but because the function signature is defined statically and the actual generic item is being retrieved in the body of the function (dynamically) it does not type hint the real type that I want to be passed.

class BaseModelSubclass(BaseModel):
    some_param: int

generic_item_store = GenericItemStore(
    items=[
        GenericItem(name="First", type_that_i_need_in_typehint=BaseModelSubclass),
        GenericItem(name="Second", type_that_i_need_in_typehint=None),
    ]
)
# actual_instance_of_type is type hinted by PyCharm as T because it's a static func signature
# I want it to be typehinted as BaseModelSubclass but don't know how and if it's even possible
generic_item_store.some_method_that_need_typehints("First", actual_instance_of_type=...)

Is there a way to somehow typehint this parameter? Is it even possible with current python typing system or I just understand it wrong? Maybe I should completely change the way of structuring this application?

I tried:

  • using a custom generic Registry instead of dict

  • mypy.cast

Any help is appreciated.


Solution

  • Is there a way to somehow typehint this parameter? Is it even possible with current python typing system or I just understand it wrong? Maybe I should completely change the way of structuring this application?

    You are correct that this is not (yet) possible. There is currently no way to define a statically-shaped dict other than TypedDict, and a TypedDict can only ever be defined using class D(TypedDict)/D = TypedDict('D', ...), in which all keys must be hardcoded.

    To add on top of that, you also can't derive the Literal[] type consisting of all keys of a TypedDict, nor can you get the union type of its values like you can in, say, TypeScript with its keyof and Type[keyof Type].

    If you are really determined, this is about as good as you could get it to work, but at this point you might as well use typing.cast() everywhere:

    (playgrounds: Mypy, Pyright)

    class GenericItem(Generic[T]):
        def __init__(self, name: str) -> None:
            self.name: str = name
    
    class Items(TypedDict):
        First: GenericItem[BaseModelSubclass]
        Second: GenericItem[None]
    
    class GenericItemStore:
        registry: Items
    
        def __init__(self, items: Items) -> None: ...
        
        @overload
        def method(self, name: Literal['First'], instance: BaseModelSubclass) -> None: ...
        
        @overload
        def method(self, name: Literal['Second'], instance: None) -> None: ...
    
        def method(self, name: Literal['First', 'Second'], instance: BaseModel | None) -> None:
            generic_item = self.registry[name]
            reveal_type(generic_item)  # GenericItem[BaseModelSubclass] | GenericItem[None]
    
    generic_item_store = GenericItemStore(
        items = Items(
            First = GenericItem[BaseModelSubclass]('First'),
            Second = GenericItem[None]('Second'),
        )
    )
    
    generic_item_store.method("First", BaseModelSubclass())   # fine
    generic_item_store.method("First", None)                  # error
    generic_item_store.method("Second", None)                 # fine
    generic_item_store.method("Second", BaseModelSubclass())  # error