mypy icon indicating copy to clipboard operation
mypy copied to clipboard

`TypeIs` narrowing of union type in a generic is not working

Open mbikovitsky opened this issue 1 month ago • 2 comments

Bug Report

Given an object of type int | bytes and a TypeIs function that narrows int | str | bytes into int | str, mypy correctly infers that the original object is really just int.

However, this doesn't work if the union is a generic parameter. That is, given Sequence[int | bytes] and a narrowing function from Sequence[int | str | bytes] to Sequence[int | str], mypy doesn't infer that the original is Sequence[int]. Instead, it infers that the original is Sequence[int | str].

I think this is a bug? But of course I might just be holding it wrong. Any help will be greatly appreciated.

To Reproduce

from typing import Callable, TypeIs, Sequence, assert_type

def check_plain(value: int | str | bytes) -> TypeIs[int | str]:
    return isinstance(value, int | str)

def check_sequence(seq: Sequence[int | str | bytes]) -> TypeIs[Sequence[int | str]]:
    return all(isinstance(value, int | str) for value in seq)

def works(value: int | bytes) -> None:
    assert check_plain(value)
    assert_type(value, int)

def doesnt(value: Sequence[int | bytes]) -> None:
    assert check_sequence(value)
    assert_type(value, Sequence[int])

https://mypy-play.net/?mypy=1.19.0&python=3.13&gist=100053f110206f3fdf37170ea75a3ca2

Actual Behavior

main.py:15: error: Expression is of type "Sequence[int | str]", not "Sequence[int]"  [assert-type]
Found 1 error in 1 file (checked 1 source file)

Your Environment

  • Mypy version used: 1.19.0
  • Mypy command-line flags: None
  • Mypy configuration options from mypy.ini (and other config files): None
  • Python version used: 3.13

mbikovitsky avatar Dec 03 '25 16:12 mbikovitsky

Pyright gives even funnier results - it considers this assert always-false, marking assert_type as unreachable. Clearly an intersection Sequence[int | bytes] & Sequence[int | str] isn't empty. Pyrefly does the same, inferring Never as the type. ty infers the correct intersection but refuses to simplify it further - it literally says Sequence[int | bytes] & Sequence[int | str] (so assert_type also fails), and the resulting type is essentially unusable (value[0] reveals type @Todo). You've got a testcase all current typecheckers choke on...

Now, this is actually debatable. I'm almost certain that pyright/pyrefly behavior is undesired (such intersection can sensibly exist at runtime), but inferring too general type is not a bug per se, in some cases mypy has to stop somewhere and say "no, we can't refine this further for the sake of our sanity". I'd say that improving inference in this case would make a great fix, but shouldn't have too high priority.

sterliakov avatar Dec 04 '25 01:12 sterliakov

in some cases mypy has to stop somewhere and say "no, we can't refine this further for the sake of our sanity"

That's completely understandable. Thanks for looking into it.

mbikovitsky avatar Dec 04 '25 05:12 mbikovitsky