pythonpython-typingtypeguards

How to create a TypeGuard that mimics isinstance


I have to check an object that may have been created by an API. When I try using isinstance(obj, MyClass) it get a TypeError if obj was created by the API.

I wrote a custom function to handle this.

def is_instance(obj: Any, class_or_tuple: Any) -> bool:
    try:
        return isinstance(obj, class_or_tuple)
    except TypeError:
        return False

The issue I am having is using is_instance() instead of the builtin isinstance() does not have any TypeGuard support, so the type checker complains.

def my_process(api_obj: int | str) -> None:
    if is_instance(api_obj, int):
        process_int(api_obj)
    else:
        process_str(api_obj)

"Type int | str cannot be assigned to parameter ..."

How could I create a TypeGuard for this function?


Solution

  • You can annotate is_instance with a TypeGuard that narrows the type to that of the second argument. To handle a tuple of types or such tuples as class_or_tuple, use a type alias that allows either a type or a tuple of the type alias itself:

    T = TypeVar('T')
    ClassInfo: TypeAlias = type[T] | tuple['ClassInfo', ...]
    
    def is_instance(obj: Any, class_or_tuple: ClassInfo) -> TypeGuard[T]:
        try:
            return isinstance(obj, class_or_tuple)
        except TypeError:
            return False
    

    But then, as @user2357112 points out in the comment, TypeGuard isn't just meant for narrowing by type, but also value, so failing a check of is_instance(api_obj, int) doesn't mean to the type checker that api_obj is necessarily str, so using an else clause would not work:

    def my_process(api_obj: int | str) -> None:
        if is_instance(api_obj, int):
            process_int(api_obj)
        else:
            # mypy complains: Argument 1 to "process_str" has incompatible type "int | str"; expected "str"  [arg-type]
            process_str(api_obj)
    

    so in this case you would have to work around it with a redundant call of is_instance(api_obj, str):

    def my_process(api_obj: int | str) -> None:
        if is_instance(api_obj, int):
            process_int(api_obj)
        elif is_instance(api_obj, str):
            process_str(api_obj)
    

    Demo with mypy: https://mypy-play.net/?mypy=latest&python=3.12&gist=4cea456751dff62c3e0bc998b74462f5

    Demo of type narrowing with a tuple of types with mypy: https://mypy-play.net/?mypy=latest&python=3.12&gist=98ca0795a315e541c4b1b9376d81812f

    EDIT: For PyRight as you requested in the comment, you would have to make the type alias generic:

    T = TypeVar('T')
    ClassInfo: TypeAlias = Union[Type[T] , Tuple['ClassInfo[T]', ...]]
    
    def is_instance(obj: Any, class_or_tuple: ClassInfo[T]) -> TypeGuard[T]:
        try:
            return isinstance(obj, class_or_tuple)
        except TypeError:
            return False
    

    This would make mypy complain, however, so it's really down to different type checkers having different interpretations to the Python typing rules.

    Demo with PyRight here