2025, Sep 25 09:00

When Optional short-circuiting misleads mypy in Python: truthiness, falsy subclasses, and the @final fix

Learn why mypy flags Optional short-circuiting in Python: falsy subclasses can leak through. See how truthiness, subclassing, and @final shape return types.

Short-circuiting with Optional values in Python often feels elegant: write one compact expression, get a clean fallback to None. But when static typing enters the room, that convenience can collide with how type checkers reason about truthiness and subclassing. Here is a concrete case with mypy that looks like a false positive until you examine what can happen at runtime.

If a Python class does not define __bool__ or __len__, bool(instance) defaults to True.

Problem

You have a class with a simple method and a helper that accepts either an instance or None. The intuitive one-liner x and x.method() should return either the method result or None. Yet mypy reports an incompatible return type.

class BaseObj:
    def __init__(self) -> None:
        self._n = 42
    def value(self) -> int:
        return self._n
def maybe_value(x: BaseObj | None) -> int | None:
    # case: x is None => returns None
    # case: x is not None => returns 42
    # however: mypy complains...
    #   Incompatible return value type (got BaseObj | int | None), expected "int | None"
    return x and x.value()

Why this happens

The expression x and x.value() relies on runtime truthiness. If x is truthy, Python evaluates and returns x.value(); if x is falsy, it returns x itself. Given that BaseObj does not define __bool__ or __len__, an instance of BaseObj is indeed truthy. So it is tempting to think the expression can only produce int or None.

The catch is subclassing. A subclass can override __bool__ and make an instance falsy. In that case, the short-circuit returns the instance itself rather than calling value(), and the expression’s type genuinely becomes BaseObj | int | None at runtime.

class DerivedObj(BaseObj):
    def __bool__(self) -> bool:
        return False
reveal_type(maybe_value(DerivedObj()))  # "int | None" at type checking time,
                                        # but DerivedObj at runtime.

mypy is conservative here for a good reason. Because BaseObj can be subclassed and subclasses can override __bool__, mypy must account for the branch where the left operand is falsy and thus returned as-is.

Solution

If you want mypy to assume that instances cannot be falsy unless they are None, prevent subclassing that could change truthiness. Mark the class as @final. With a final class, mypy can safely conclude that bool(x) is always True for non-None instances and narrow the type accordingly.

from typing import final
@final
class BaseObj:
    def __init__(self) -> None:
        self._n = 42
    def value(self) -> int:
        return self._n
def maybe_value(x: BaseObj | None) -> int | None:
    return x and x.value()

As for a different way to write optional_obj and optional_obj.attribute, see How do I get Pylance to ignore the possibility of None?.

Why this matters

Static analysis needs to cover all valid runtime paths. The presence of subclassing means your seemingly airtight assumption about truthiness can be broken without modifying the original class. Understanding this edge case prevents surprising type errors, and more importantly, prevents subtle runtime bugs when falsy subclasses appear in a codebase.

Takeaways

If a short-circuit expression mixes an object and a value, the result may be the object itself when that object can be falsy. mypy flags this because subclassing can override truthiness. If the class is not meant to be extended or you want to guarantee stable truthiness for type narrowing, declare it @final. When you need a different style for accessing attributes on optionals, consider patterns discussed in the referenced material instead of relying on and-chaining.

The article is based on a question from StackOverflow by matheburg and an answer by InSync.