Skip to content

Specify what happens for binary operators (e.g. __add__) #1548

Open

Description

@gvanrossum

There's a subtle dance around __add__, __radd__, and NotImplemented. While most of the rules are in the language spec, I think we haven't specified carefully what the signature should be of e.g. the __add__ method, especially when annotations are inline. (At least, I didn't find any mention of NotImplemented in the spec.)

In typeshed we seem to ignore the "overload" __add__: (Any) -> Literal[NotImplemented] (to be loose with notation), but what do typecheckers do when they see a concrete __add__ or __eq__ implementation that can return NotImplemented?

Activity

  1. superbobry commented on Dec 21, 2023

    @superbobry
    Contributor

    I think type checkers can implement the semantics of NotImplemented without actually requiring it in the types. Specifically, given a + b,

    • if type(a).__add__(b) type checks, i.e. type(a) has an overload expecting b, -- use that,
    • otherwise fall back type(b).__radd__(a).

    Note also that

    • Literal[NotImplemented] is not permitted because NotImplemented is not an enum nor a literal syntactically;
    • NotImplementedType could be used to describe instances of NotImplemented, but it is not currently available from pure python AFAICT;
    • Alternatively, we could handle NotImplemented similarly to None in that NotImplemented in a type annotation means NotImplementedType.
  2. gvanrossum commented on Dec 24, 2023

    @gvanrossum
    MemberAuthor

    Those are not the exact rules. Sometimes __radd__ is called first (the rule is so complicated I don't recall offhand if that can be determined statically or not). And when both return NotImplemented, the interpreter issues the exception.

    Anyway, we might still consider this, given that the convention in typeshed appears to be not to show NotImplemented. (This would only be a problem for direct callers of these overloads, which must be very rare.)

    I am aware of the issues around Literal[NotImplemented], I just used as a shorthand (if it did work, it would be ideal :-).

  3. JelleZijlstra commented on Jan 2, 2024

    @JelleZijlstra
    Member

    NotImplemented is generally just ignored by the type system; in typeshed we treat it as being a subclass of Any

    https://github.com/python/typeshed/blob/d4daff337fc8ac122a10cff21ea2e7c9f3a30a6d/stdlib/builtins.pyi#L1261

    I think to move forward here we'd need:

    • A survey of what type checkers currently do
    • Some thinking about how we could make the behavior more useful. Are there plausible user mistakes that type checkers currently can't catch, but that could be caught if we came up with a better set of rules? Conversely, are there patterns that work at runtime that type checkers currently do not allow?
  4. tyralla commented on Oct 23, 2025

    @tyralla

    I'm currently trying to improve Mypy's handling of NotImplemented a bit. Mypy (and, I think, Pyright) do not care much about it and so miss the following simple error:

    class A1:
        x = 1
        def __add__(self, o: A1) -> float:
            if isinstance(o, A1):
                return self.x + o.x
            return NotImplemented
    
    class A2:
        x = 2
        def __add__(self, o: A2) -> float:
            return self.x + o.x
    
    class B:
        y = 3
        def __radd__(self, o: A1 | A2) -> float:
            if isinstance(o, A1 | A2):
                return self.y + o.x
            return NotImplemented
    
    A1() + B()  # works
    A2() + B()  # fails  (undetected by Mypy and Pyright)

    It is not clear to me where the type checkers fail. According to the documentation, NotImplemented ...should be returned by the binary special methods ... to indicate that the operation is not implemented with respect to the other type. This sounds more like the implementation of A2 is wrong. However, the documentation also says that NotImplemented may be returned by the in-place binary special methods ... for the same purpose. So here it is clear that returning NotImplemented is optional. I guess it would be more comprehensible to let type checkers handle both cases identically and to understand the should in the first statement also as a may.

    If so, wouldn't there be much gained by letting type checkers analyse if a (in-place) binary special method's return type annotation contains NotImplementedType? Then,

    def __add__(self, o: A1) -> float | NotImplementedType:
        if isinstance(o, A1):
            return self.x + o.x
        return NotImplemented

    could be understood as

    @overload
    def __add__(self, o: A1) -> float: ...
    @overload
    def __add__(self, o: object) -> NotImplementedType: ...

    And

    def __add__(self, o: A2) -> float:
        return self.x + o.x

    could be understood precisely as the type hints suggest.

    As NotImplementedType is available since Python 3.10 (and Python 3.9 reaches its end-of-life), type checkers would not need to implement None-like handling for NotImplemented or support Literal[NotImplemented].

    Gradually adding NotImplementedType to return type hints would hopefully work relatively smoothly. A few experiments with Mypy:

    class C1:
        def __add__(self, o: C1) -> float: ...
    class D1(C1):
        def __add__(self, o: C1) -> float | NotImplementedType: ...  # okay
    
    class C2:
        def __add__(self, o: C2) -> float | NotImplementedType: ...
    class D2(C2):
        def __add__(self, o: C2) -> float: ...  # okay
    
    reveal_type(C1() + C1())  # float
    reveal_type(D1() + D1())  # float | _NotImplementedType  (wrong but hopefully not problematic)
    reveal_type(C2() + C2())  # float | _NotImplementedType
    reveal_type(D2() + D2())  # float
    
    reveal_type((D1() + D1()) + 1.0)  # float (NotImplementedType treated Any-like)

    For Pyright, the revealed types are a little different, but at least D1() + D1()) + 1.0 causes no wrong false positive (I am not familiar with Pyright and so do not know much about Unknown):

    reveal_type(C1() + C1())  # float
    reveal_type(D1() + D1())  # float | Unknown
    reveal_type(C2() + C2())  # float | Unknown
    reveal_type(D2() + D2())  # float
    
    reveal_type((D1() + D1()) + 1.0)  # float | Unknown

    Type checkers could, of course, implement a (transition) option that lets them continue to assume that all (in-place) binary special methods eventually return NotImplemented.

  5. srittau commented on Oct 23, 2025

    @srittau
    Collaborator

    I think the best solution would be the following annotations:

    @overload
    def __add__(self, o: A1) -> float: ...
    @overload
    def __add__(self, o: object) -> NotImplementedType: ...
    def __add__(self, o: object) -> float | NotImplementedType:
        if isinstance(o, A1):
            return self.x + o.x
        return NotImplemented

    (This would rely on interpreting overloads in declaration order, though. As I understand it, that's not necessarily true anymore, though.)

    Your suggestion of interpreting def __add__(self, o: A1) -> float | NotImplementedType: like the explicit annotations above would necessitate further special handling in this case. It's also unclear what an annotation like def __add__(self, o: A1 | int) -> float | str | NotImplementedType: would mean.

  6. tyralla commented on Oct 23, 2025

    @tyralla
    def __add__(self, o: A1 | int) -> float | str | NotImplementedType: ...

    would mean

    @overload
    def __add__(self, o: A1 | int) -> float | str: ...
    @overload
    def __add__(self, o: object) -> NotImplementedType: ...

    But I see that repeating NotImplementedType in multiple overloads looks a bit silly:

    @overload
    def __add__(self, o: A1) -> float | NotImplementedType: ...
    @overload
    def __add__(self, o: int) -> str | NotImplementedType: ...

    Yes, my suggestion to allow "implicit overloads" would require some special casing in type checkers, but it would reduce code repetition (__add__, __radd__, __sub__...). And the binary special methods already require much special handling. Maybe a compromise could be to allow "implicit overloads" only for plain methods (without "normal overloads"). But I have no strong opinion here; I think your suggestion to require "explicit overloads" is equally fine.

    Maybe one could write a helpful decorator for this purpose?

    @may_return_notimplemented
    def __add__(self, o: A1) -> float: ...
  7. srittau commented on Oct 23, 2025

    @srittau
    Collaborator

    Yes, my suggestion to allow "implicit overloads" would require some special casing in type checkers, but it would reduce code repetition (add, radd, sub...).

    My main problem is not the special casing in type checkers, but the special casing in user's minds. Users need to know about it, and it's another special case for a not super common feature.

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

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions