How to get a type check error on `__bool__`

For some types it is an error to call __bool__ e.g. for numpy arrays:

import numpy as np

b = np.array([1, 2, 3])

if b < 3: # should be a type check error
    pass

If you run the above then:

Traceback (most recent call last):
  File "t.py", line 5, in <module>
    if b < 3: # should be a type check error
       ^^^^^
ValueError: The truth value of an array with more than one element is ambiguous. Use a.any() or a.all()

Is there some way to add annotations here so that a type checker can understand this:

class Foo:
   def __bool__(self):
        raise TypeError

I’m guessing that this is just not possible right now.

At least on pyright, this gives an error on usage:

from typing import Never

class Foo:
   def __bool__(self) -> Never:
        raise TypeError

if Foo():
    print("Hello World")

Thanks. Yes, pyright rejects that although mypy allows it.

The error from pyright is:

  t.py:9:4 - error: Invalid conditional operand of type "Foo"
    Method __bool__ for type "Foo" returns type "Never" rather than "bool" (reportGeneralTypeIssues)
1 error, 0 warnings, 0 informations 

Actually it seems that it can be typed as returning anything e.g. complex, str, int or Never and then pyright will always reject it and mypy will always accept it.

It is not entirely clear to me if any of those cases should be considered a bug in mypy or pyright.

Should mypy treat this is an error?

Should the return annotation be changed in numpy?

For what it’s worth, I ran into a similar issue recently. My would-be solution was to mark __bool__ as @deprecated with a nice message that I expected the static type checker to display (with deprecation-as-error enabled for that module). Unfortunately, it seems like there is special casing for __bool__ that prevents this from working.

1 Like

You have to turn on warn-unreachable in mypy to get any errors involving Never returns, since Never only really changes which parts of the code are reachable. It tends to be generally an unsatisfying solution and people often abuse Never in functions, leading to confusing errors.

It would be nice to be able to trigger custom type errors for specific function calls and overloads, similar to @deprecated. In the past there was a suggestion for a @type_error decorator, since it’s quite common with overloaded functions that use arguments like Iterable[T] to have overlap with invalid types like str. However it never really went anywhere. I think it would be valuable, if only to get people to stop abusing Never for this purpose in functions.

It is not really abuse. This is exactly the correct typing for this function, since it never returns (alternative you could use NoReturn, but Never == NoReturn, and I like Never).

But I think it is also valid for mypy to not complain about this from a type theory standpoint.

1 Like

Yes, it models the control-flow correctly, so I don’t think it’s the worst offense, but it’s still not really what Never/NoReturn was designed for. It’s designed for functions like sys.exit, where it’s completely normal for people to call them in a correct program. But you want to be able to detect dead code after the call of sys.exit, since that’s a common bug.

But in this case it’s an error to call the function at all. So you would expect an error to be emitted at the call-site, which pyright happens to emit in this case, purely because __bool__ is expected to return bool, it doesn’t really have anything to do with Never. With other methods you wouldn’t see an error at the call-site but with the unreachable code.

Technically Never should be a valid return type for __bool__, since Never is the bottom type and thus considered a subtype of bool. So you could even argue that it’s wrong for pyright to emit an error there. It’s just one of those cases where it’s currently more useful to be pragmatic, rather than correct, since we don’t have a tool like @type_error.

1 Like

I definitely think it is an abuse. It’s also a foot gun. Mypy and pyright will stop type checking as soon as they encounter Never/NoReturn.

from typing import Never, overload, Sequence


@overload
def test(args: str) -> Never: ...
@overload
def test(args: Sequence[str]) -> int: ...
def test(args: Sequence[str]) -> int:
    return len(args)

test("a")
test(124) # runtime error
c: str = 0 # type error

Neither mypy nor pyright complain about these errors because they deem it unreachable and stop type checking after hitting Never. I don’t think their behaviour is wrong here. They are correct to consider it unreachable.

See:

1 Like

Never is the correct return annotation here. It’s not abuse in any way. Functions that will not return a value when called should be typed with it.

I find pyright’s behavior here slightly more useful, but both type checkers are fine from a theory perspective.

I think the definition of Never being just the bottom type, with no other information about why it is used causes a few other problems, but I don’t see that changing right now[1]

This sounds like (paired with a correct Never return annotation) the right option for this case, and I’d call that a bug in whatever type checker isn’t honoring it.


  1. There are multiple reasons something can be Never, and what that means for static analysis should be meaningfully different. This quickly leads to algebraic effects, something I’m not going to propose for python when we can’t even get people to stop writing LSP violations. ↩︎

1 Like

I don’t think any either mypy or pyright are honouring it at the moment? In fact, I think I couldn’t get any type checker to consider my specific __bool__ implementation at all. IIRC there are a bunch of issues about that.

At least as far as mypy goes, it’s because truthiness checks that invoke __bool__ don’t go through the same code path as regular function calls, since there are no arguments to check. The only reason Never sometimes produces correct results with branches invoking __bool__, is because it’s considered as neither truthy, nor falsy.

You can probably find quite a few corner cases where it does the wrong thing. E.g. you can assign None to __bool__ and mypy will not emit an error for truthiness checks:

mypy Playground

class Foo:
   __bool__: None

if Foo():  # no error
    print("Hello World")  # no unreachable error

Other type checkers probably make similar simplifications/assumptions, that lead to bad edge cases like this one.

I’d call such assumptions what they are, incorrect understandings of python’s object model that result in the feature not working, or more plainly, a bug.

I’m not disagreeing with you, until recently the check didn’t even take the metaclass into account, which lead to incorrect results with enums[1]. Although to be fair, type checkers have to do a balancing act between correctly covering all the edge cases and keeping performance adequate for checks that are happening a lot.

Very few people are going to be happy with a type checker that catches 1% more bugs but is ten times as slow. That being said, anyone is welcome to open an exploratory pull request, which improves accuracy.


  1. There was another bug involving enum literals which was hiding the incorrect handling of the enum metaclass ↩︎

It is unclear to me what if anything should be considered a bug in type checkers here but it would be great if there was some unambiguous way to indicate that using if with some objects is an error. In the numpy case here it is a fairly clear error but it is value dependent:

In [1]: bool(np.array([1]))
Out[1]: True

In [2]: bool(np.array([1,1]))
...
ValueError: The truth value of an array with more than one element is ambiguous. Use a.any() or a.all()

In sympy this error is more problematic because it can not happen much of the time but then sometimes happen if symbols are involved:

In [8]: from sympy import *

In [4]: x = symbols('x')

In [9]: e1 = sqrt(2)

In [10]: e2 = sqrt(x)

In [11]: e1
Out[11]: √2

In [12]: e2
Out[12]: √x

In [13]: e1 > 0
Out[13]: True

In [14]: e2 > 0
Out[14]: √x > 0

In [15]: bool(e1 > 0)
Out[15]: True

In [16]: bool(e2 > 0)
...
TypeError: cannot determine truth value of Relational: sqrt(x) > 0

This is actually a common source of bugs and it would be great if type checkers could help because runtime testing can easily miss it.

2 Likes

One can use def method(self: Never) -> ... as an alternative to @deprecated.
But as already noted,

The signature of __bool__ seems to be ignored for the most part, which defeats my suggestion as well.


For general use (and to lift the meaning burden off Never), I think an addition of some @no_call decorator would be great, particularly for marking invalid overloads (which otherwise are cumbersome to write and/or would require intersection types and Not[])

from typing import Never, assert_never
from warnings import deprecated

class A:
    @deprecated("...")
    def __bool__(self) -> Never:
        raise RuntimeError("...")

if (x := A()):  # bug if type checker *doesnt* error here (pyright does, mypy doesn't)
    assert_never(x)  # bug if type checker errors here  (pyright does, mypy doesn't)

playgrounds: pyright mypy

(pyre’s playground doesn’t support 3.13 yet)

so both of the major type checkers that support 3.13 (where this should work) do something wrong here.

Source for this being specified and that it’s supposed to work for dunders: Type checker directives — typing documentation

Type checkers should produce a diagnostic whenever they encounter a usage of an object marked as deprecated. For deprecated overloads, this includes all calls that resolve to the deprecated overload. For deprecated classes and functions, this includes:

  • Any syntax that indirectly triggers a call to the function. For example, if the __add__ method of a class C is deprecated, then the code C() + C() should trigger a diagnostic. Similarly, if the setter of a property is marked deprecated, attempts to set the property should trigger a diagnostic.

I realized it’s arguable that the x binds to A(), so one could argue the type of x isn’t Never (incorrectly IMO, the scope is unreachable, so x has no type in the scope)

The following example has consistent behavior without that place of discrepancy, and both type checkers are failing both parts of if with the modified example here:

from typing import Never, assert_never
from warnings import deprecated

class A:
    @deprecated("...")
    def __bool__(self) -> Never:
        raise RuntimeError("...")

if (x := bool(A())):  # bug if type checker *doesnt* error here (pyright doesn't, mypy doesn't)
    assert_never(x)  # bug if type checker errors here  (pyright does, mypy does)

playgrounds: pyright mypy