Skip to content

False positive list-item: list literal checked against list[bool] when the union also contains Hashable #22342

Open

Description

@cmp0xff

Bug Report

When a parameter is a union of list[bool], Hashable and MutableSequence[str], mypy reports ["a", "b"] as an invalid list[bool]. The MutableSequence[str] member accepts it. Dropping Hashable, or replacing it with int, makes the error go away. Pyright, pyrefly and ty accept the call. This looks related to #3506.

To Reproduce

https://mypy-play.net/?gist=035e1c2653c71379a5231a139f9f2fa2

from collections.abc import Hashable, MutableSequence

def f(k: list[bool] | Hashable | MutableSequence[str]) -> None: ...

f(["a", "b"])  # mypy: List item 1 has incompatible type "str"; expected "bool"  [list-item]

What would work on mypy 2.4.1:

  • list[bool] | MutableSequence[str]
  • list[bool] | int | MutableSequence[str]
  • x = ["a","b"]; f(x).

Expected Behaviour

passing

Actual Behavior

main.py:5: error: List item 0 has incompatible type "str"; expected "bool"  [list-item]
main.py:5: error: List item 1 has incompatible type "str"; expected "bool"  [list-item]
Found 2 errors in 1 file (checked 1 source file)

Your Environment

  • Mypy version used: 2.4.1 / 934e703
  • Mypy command-line flags: none
  • Mypy configuration options from mypy.ini (and other config files): none
  • Python version used: 3.15

Activity

  1. added a commit that references this issue on Nov 1, 2026
  2. added a commit that references this issue on Nov 2, 2026
    a5c1d90
  3. hamarshehmhmd commented on Nov 3, 2026

    @hamarshehmhmd

    Root cause is union simplification in mypy/constraints.py combined with non-transitive subtyping.

    _infer_constraints() simplifies the context union before inferring list[T] <: list[bool] | Hashable | MutableSequence[str]. Since typeshed's MutableSequence doesn't set __hash__ = None, MutableSequence[str] is a proper subtype of Hashable, so it gets dropped and the union becomes list[bool] | Hashable. But list does set __hash__ = None, so list[T] can't match Hashable (infer_constraints_if_possible returns None). That leaves list[bool] as the only option, so T = bool, and the items get checked against bool. That's also why swapping Hashable for int makes it go away: nothing gets absorbed.

    Fix I tested: keep the unsimplified items around, and in the SUBTYPE_OF + union-context branch, add back the option for a dropped item X only when the template can match X but every item that absorbed X can't. That's exactly the "subtyping wasn't transitive here" case, so normal simplification behavior (e.g. list[int] | Sequence[int]) stays the same.

    --- a/mypy/constraints.py
    +++ b/mypy/constraints.py
    @@ def _infer_constraints(
         if not type_state.keep_unions and isinstance(template, UnionType):
             template = mypy.typeops.make_simplified_union(template.items, keep_erased=True)
    +    unsimplified_actual_items: list[Type] | None = None
         if not type_state.keep_unions and isinstance(actual, UnionType):
    +        unsimplified_actual_items = actual.items
             actual = mypy.typeops.make_simplified_union(actual.items, keep_erased=True)
    @@
         if direction == SUBTYPE_OF and isinstance(actual, UnionType):
    -        return any_constraints(
    -            [
    -                infer_constraints_if_possible(template, a_item, direction)
    -                for a_item in actual.items
    -            ],
    -            eager=True,
    -        )
    +        options = [
    +            infer_constraints_if_possible(template, a_item, direction) for a_item in actual.items
    +        ]
    +        if unsimplified_actual_items is not None:
    +            options.extend(
    +                _options_lost_by_simplification(
    +                    template, actual.items, options, unsimplified_actual_items
    +                )
    +            )
    +        return any_constraints(options, eager=True)
    @@
    +def _options_lost_by_simplification(
    +    template: ProperType,
    +    simplified_items: Sequence[Type],
    +    simplified_options: list[list[Constraint] | None],
    +    original_items: Sequence[Type],
    +) -> list[list[Constraint] | None]:
    +    """Constraint options for union items dropped by simplification that we still need.
    +
    +    Simplification drops X if it's a proper subtype of another item Y, but subtyping
    +    isn't always transitive (MutableSequence[str] <: Hashable, list[str] isn't Hashable).
    +    If the template can match X but none of the items that absorbed X can, keep X.
    +    """
    +    simplified_ids = {id(get_proper_type(t)) for t in simplified_items}
    +    extra: list[list[Constraint] | None] = []
    +    for item in original_items:
    +        p_item = get_proper_type(item)
    +        if id(p_item) in simplified_ids or isinstance(p_item, UnionType):
    +            continue
    +        absorbers = [
    +            option
    +            for y, option in zip(simplified_items, simplified_options)
    +            if mypy.subtypes.is_proper_subtype(item, y, keep_erased_types=True)
    +        ]
    +        if not absorbers or any(option is not None for option in absorbers):
    +            continue
    +        option = infer_constraints_if_possible(template, item, SUBTYPE_OF)
    +        if option is not None:
    +            extra.append(option)
    +    return extra

    With this, [bool] and [str] become two competing options, any_constraints gives up on the context, and T is inferred from the items, which is what pyright/ty do.

    Tested on master (623bef9):

    • your repro passes; f([True]) still passes; g(["a"]) with list[bool] | Hashable still errors with list-item
    • f([1.5]) still errors, but now as arg-type against the whole union (with the __hash__ conflict note) instead of list-item vs bool, which I think is more accurate
    • added a check test with a custom protocol/ClassVar[None] override (fixtures don't model list.__hash__) and a pythoneval test with the exact repro; both fail before, pass after
    • testcheck.py, testtypes.py, testsolve.py, testinfer.py: 8503 passed, 29 skipped, 7 xfailed
    • self-check on mypy/constraints.py is clean

    Happy to open a PR if this approach looks right.

  4. added a commit that references this issue on Nov 4, 2026
    8a02ccb
  5. added 2 commits that reference this issue on Nov 6, 2026
    e00c840
    02f793e
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugmypy got something wrong

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions