typeshed icon indicating copy to clipboard operation
typeshed copied to clipboard

polymorphic overloads on `list.__add__` and `dict.__or__` lead to divergences in type checkers.

Open randolf-scholz opened this issue 8 months ago • 1 comments

The overloads on list.__add__ lead to divergent behavior between mypy and pyright when doing something as simple as a list concatenation

    # Overloading looks unnecessary, but is needed to work around complex mypy problems
    @overload
    def __add__(self, value: list[_T], /) -> list[_T]: ...
    @overload
    def __add__(self, value: list[_S], /) -> list[_S | _T]: ...

Code sample in pyright playground, https://mypy-play.net/?mypy=latest&python=3.12&gist=abf6a8834020af17a16bd8cfb44b2f10

from typing import Any, overload

class ListA[T]:  # emulates builtins list
    @overload
    def __add__(self, other: "ListA[T]", /) -> "ListA[T]": return ListA()
    @overload
    def __add__[S](self, other: "ListA[S]", /) -> "ListA[T | S]": return ListA()
    
    
class ListB[T]:  # without overloads
    def __add__[S](self, other: "ListB[S]", /) -> "ListB[T | S]": return ListB()

                                            # mypy              | pyright                             
reveal_type( list[str]() + list[str]() )    # list[str]         | list[str]          ✅️
reveal_type( list[str]() + list[int]() )    # list[str | int]   | list[str | int]    ✅️
reveal_type( list[str]() + list[Any]() )    # list[Any]         | list[str]          ❌️

reveal_type( ListA[str]() + ListA[str]() )  # ListA[str]        | ListA[str]         ✅️
reveal_type( ListA[str]() + ListA[int]() )  # ListA[str | int]  | ListA[str | int]   ✅️
reveal_type( ListA[str]() + ListA[Any]() )  # ListA[Any]        | ListA[str]         ❌️

reveal_type( ListB[str]() + ListB[str]() )  # ListB[str]        | ListB[str]         ✅️
reveal_type( ListB[str]() + ListB[int]() )  # ListB[str | int]  | ListB[str | int]   ✅️
reveal_type( ListB[str]() + ListB[Any]() )  # ListB[str | Any]  | ListB[str | Any]   ✅️

This ultimately causes some very annoying type errors when checking wrapper functions in pyright.

Code sample in pyright playground

from typing import Mapping, Any

# function with 2 optional arguments
def foo(arg: object, /, *, opt1: str = ..., opt2: int = ...) -> None: ...

# wrapper that forwards args via dict
def foo_wrapper(arg: object, foo_kwargs: Mapping[str, Any]) -> None:
    # apply new defaults
    foo_kwargs = {"opt1": "new_default"} | dict(foo_kwargs)
    foo(arg, **foo_kwargs)  # "str" cannot be assigned to parameter "opt2"

PR #14282 and #14284 show mypy-primer results of simplifying the overloads away from list.__add__ and dict.__or__.

randolf-scholz avatar Jun 16 '25 11:06 randolf-scholz

We have some tests for dict.__or__ in https://github.com/python/typeshed/blob/main/stdlib/%40tests/test_cases/builtins/check_dict.py ; if your edge case isn't covered there already (and it doesn't seem to be) it'd be nice to flesh that out. Seeing the changes needed to the test suite following the removal of the overloads can be clarifying.

We don't have much in the way of tests characterizing list methods right now, so that's a good opportunity too.

tungol avatar Aug 21 '25 05:08 tungol