Skip to content

Bounded generic type parameter not narrowed past its upper bound, regardless of argument value #19285

Open
@alythobani

Description

@alythobani

Bug Report

If a generic type variable _E has an upper bound B, the upper bound B seems to be used as the inferred type of _E when inferring other type variables that depend on _E, even if there is a more specific type that _E could be narrowed to.

This becomes an issue when trying to infer error types returned by poltergeist (a simple library that provides a generic Result = Ok[_T] | Err[_E] utility type).

To Reproduce

from poltergeist import catch

@catch(TypeError, ValueError)
def validate_positive_number(int_or_str: int | str) -> int:
    if isinstance(int_or_str, str):
        raise TypeError("Input must be an integer")
    if int_or_str <= 0:
        raise ValueError("Input must be positive")
    return int_or_str


def do_something_with_positive_number(int_or_str: int | str) -> None:
    validated_number_result = validate_positive_number(int_or_str)
    reveal_type(validated_number_result)  # Revealed type is "Union[poltergeist.result.Ok[builtins.int], poltergeist.result.Err[builtins.Exception]]" Mypy

The implementation of catch can be found here:

Source code for the `catch` decorator
import functools
from collections.abc import Awaitable
from typing import Callable, ParamSpec, TypeVar, reveal_type

from poltergeist.result import Err, Ok, Result

_T = TypeVar("_T")
_E = TypeVar("_E", bound=BaseException)
_P = ParamSpec("_P")


def catch(
    *errors: type[_E],
) -> Callable[[Callable[_P, _T]], Callable[_P, Result[_T, _E]]]:
    def decorator(func: Callable[_P, _T]) -> Callable[_P, Result[_T, _E]]:
        @functools.wraps(func)
        def wrapper(*args: _P.args, **kwargs: _P.kwargs) -> Result[_T, _E]:
            try:
                result = func(*args, **kwargs)
            except errors as e:
                return Err(e)
            return Ok(result)

        return wrapper

    return decorator


def catch_async(
    *errors: type[_E],
) -> Callable[[Callable[_P, Awaitable[_T]]], Callable[_P, Awaitable[Result[_T, _E]]]]:
    def decorator(
        func: Callable[_P, Awaitable[_T]]
    ) -> Callable[_P, Awaitable[Result[_T, _E]]]:
        @functools.wraps(func)
        async def wrapper(*args: _P.args, **kwargs: _P.kwargs) -> Result[_T, _E]:
            try:
                result = await func(*args, **kwargs)
            except errors as e:
                return Err(e)
            return Ok(result)

        return wrapper

    return decorator

Expected Behavior

mypy should be able to accurately use the value of errors to narrow the inferred type of _E within catch, and thus infer that validated_number_result is of type Result[int, TypeError | ValueError].

Actual Behavior

Mypy keeps _E inferred to its upper bound BaseException, thus inferring validated_number_result as Result[int, BaseException].

By contrast, pylance's type checker is able to narrow validated_number_result as expected:

Image

Your Environment

  • Mypy version used: 1.15.0
  • Mypy configuration options from mypy.ini (and other config files):
mypy.ini
[mypy]
python_version = 3.13
mypy_path = typings
ignore_missing_imports = True
check_untyped_defs = True
disallow_untyped_defs = True
disallow_untyped_calls = True
strict_equality = True
disallow_any_unimported = True
warn_return_any = True
no_implicit_optional = True
pretty = True
show_error_context = True
show_error_codes = True
show_error_code_links = True
no_namespace_packages = True
  • Python version used: 3.13.0

Unsure if related to #19081 , but it's possible, given that both these issues are related to retaining information via generic type parameters.

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugmypy got something wrongtopic-join-v-unionUsing join vs. using unions

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions