Back to Ruff

Narrowing For Truthiness Checks (`if x` or `if not x`)

crates/ty_python_semantic/resources/mdtest/narrow/truthiness.md

0.15.1213.9 KB
Original Source

Narrowing For Truthiness Checks (if x or if not x)

Value Literals

py
from typing import Literal, TypeAlias

X: TypeAlias = Literal[0, -1, True, False, "", "foo", b"", b"bar", None] | tuple[()]

def _(x: X):
    if x:
        reveal_type(x)  # revealed: Literal[-1, True, "foo", b"bar"]
    else:
        reveal_type(x)  # revealed: Literal[0, False, "", b""] | None | tuple[()]

def _(x: X):
    if not x:
        reveal_type(x)  # revealed: Literal[0, False, "", b""] | None | tuple[()]
    else:
        reveal_type(x)  # revealed: Literal[-1, True, "foo", b"bar"]

def _(x: X):
    if x and not x:
        reveal_type(x)  # revealed: Never
    else:
        reveal_type(x)  # revealed: Literal[0, -1, "", "foo", b"", b"bar"] | bool | None | tuple[()]

def _(x: X):
    if not (x and not x):
        reveal_type(x)  # revealed: Literal[0, -1, "", "foo", b"", b"bar"] | bool | None | tuple[()]
    else:
        reveal_type(x)  # revealed: Never

def _(x: X):
    if x or not x:
        reveal_type(x)  # revealed: Literal[-1, 0, "foo", "", b"bar", b""] | bool | None | tuple[()]
    else:
        reveal_type(x)  # revealed: Never

def _(x: X):
    if not (x or not x):
        reveal_type(x)  # revealed: Never
    else:
        reveal_type(x)  # revealed: Literal[-1, 0, "foo", "", b"bar", b""] | bool | None | tuple[()]

def _(x: X):
    if (isinstance(x, int) or isinstance(x, str)) and x:
        reveal_type(x)  # revealed: Literal[-1, True, "foo"]
    else:
        reveal_type(x)  # revealed: Literal[b"", b"bar", 0, False, ""] | None | tuple[()]

Walrus Member Access

We can narrow on an attribute expression, even when its base is a named expression:

py
class Foo:
    val: int | None

if (foo := Foo()).val:
    reveal_type(foo.val)  # revealed: int & ~AlwaysFalsy

But we don't pick up stale narrowings from before the assignment in the named expression:

py
foo1 = Foo()
foo1.val = None
if (foo1 := Foo()).val:
    reveal_type(foo1.val)  # revealed: int & ~AlwaysFalsy

Function Literals

Basically functions are always truthy.

py
def flag() -> bool:
    return True

def foo(hello: int) -> bytes:
    return b""

def bar(world: str, *args, **kwargs) -> float:
    return 0.0

x = foo if flag() else bar

if x:
    reveal_type(x)  # revealed: (def foo(hello: int) -> bytes) | (def bar(world: str, *args, **kwargs) -> int | float)
else:
    reveal_type(x)  # revealed: Never

Mutable Truthiness

Truthiness of Instances

The boolean value of an instance is not always consistent. For example, __bool__ can be customized to return random values, or in the case of a list(), the result depends on the number of elements in the list. Therefore, these types should not be narrowed by if x or if not x.

py
class A: ...
class B: ...

def f(x: A | B):
    if x:
        reveal_type(x)  # revealed: (A & ~AlwaysFalsy) | (B & ~AlwaysFalsy)
    else:
        reveal_type(x)  # revealed: (A & ~AlwaysTruthy) | (B & ~AlwaysTruthy)

    if x and not x:
        reveal_type(x)  # revealed: (A & ~AlwaysFalsy & ~AlwaysTruthy) | (B & ~AlwaysFalsy & ~AlwaysTruthy)
    else:
        reveal_type(x)  # revealed: A | B

    if x or not x:
        reveal_type(x)  # revealed: A | B
    else:
        reveal_type(x)  # revealed: (A & ~AlwaysTruthy & ~AlwaysFalsy) | (B & ~AlwaysTruthy & ~AlwaysFalsy)

Truthiness of Types

Also, types may not be Truthy. This is because __bool__ can be customized via a metaclass. Although this is a very rare case, we may consider metaclass checks in the future to handle this more accurately.

py
def flag() -> bool:
    return True

x = int if flag() else str
reveal_type(x)  # revealed: <class 'int'> | <class 'str'>

if x:
    reveal_type(x)  # revealed: (<class 'int'> & ~AlwaysFalsy) | (<class 'str'> & ~AlwaysFalsy)
else:
    reveal_type(x)  # revealed: (<class 'int'> & ~AlwaysTruthy) | (<class 'str'> & ~AlwaysTruthy)

Determined Truthiness

Some custom classes can have a boolean value that is consistently determined as either True or False, regardless of the instance's state. This is achieved by defining a __bool__ method that always returns a fixed value.

These types can always be fully narrowed in boolean contexts, as shown below:

py
from typing import Literal

class T:
    def __bool__(self) -> Literal[True]:
        return True

class F:
    def __bool__(self) -> Literal[False]:
        return False

t = T()

if t:
    reveal_type(t)  # revealed: T
else:
    reveal_type(t)  # revealed: Never

f = F()

if f:
    reveal_type(f)  # revealed: Never
else:
    reveal_type(f)  # revealed: F

Narrowing Complex Intersection and Union

py
from typing import Literal

class A: ...
class B: ...

def flag() -> bool:
    return True

def instance() -> A | B:
    return A()

def literals() -> Literal[0, 42, "", "hello"]:
    return 42

x = instance()
y = literals()

if isinstance(x, str) and not isinstance(x, B):
    reveal_type(x)  # revealed: A & str & ~B
    reveal_type(y)  # revealed: Literal[0, 42, "", "hello"]

    z = x if flag() else y

    reveal_type(z)  # revealed: (A & str & ~B) | Literal[0, 42, "", "hello"]

    if z:
        reveal_type(z)  # revealed: (A & str & ~B & ~AlwaysFalsy) | Literal[42, "hello"]
    else:
        reveal_type(z)  # revealed: (A & str & ~B & ~AlwaysTruthy) | Literal[0, ""]

Narrowing Multiple Variables

py
from typing import Literal

def f(x: Literal[0, 1], y: Literal["", "hello"]):
    if x and y and not x and not y:
        reveal_type(x)  # revealed: Never
        reveal_type(y)  # revealed: Never
    else:
        # ~(x or not x) and ~(y or not y)
        reveal_type(x)  # revealed: Literal[0, 1]
        reveal_type(y)  # revealed: Literal["", "hello"]

    if (x or not x) and (y and not y):
        reveal_type(x)  # revealed: Never
        reveal_type(y)  # revealed: Never
    else:
        # ~(x or not x) or ~(y and not y)
        reveal_type(x)  # revealed: Literal[0, 1]
        reveal_type(y)  # revealed: Literal["", "hello"]

Control Flow Merging

After merging control flows, when we take the union of all constraints applied in each branch, we should return to the original state.

py
class A: ...

x = A()

if x and not x:
    y = x
    reveal_type(y)  # revealed: A & ~AlwaysFalsy & ~AlwaysTruthy
else:
    y = x
    reveal_type(y)  # revealed: A

reveal_type(y)  # revealed: A

Truthiness of classes

py
from typing import Literal

class MetaAmbiguous(type):
    def __bool__(self) -> bool:
        return True

class MetaFalsy(type):
    def __bool__(self) -> Literal[False]:
        return False

class MetaTruthy(type):
    def __bool__(self) -> Literal[True]:
        return True

class MetaDeferred(type):
    def __bool__(self) -> MetaAmbiguous:
        raise NotImplementedError

class AmbiguousClass(metaclass=MetaAmbiguous): ...
class FalsyClass(metaclass=MetaFalsy): ...
class TruthyClass(metaclass=MetaTruthy): ...
class DeferredClass(metaclass=MetaDeferred): ...

def _(
    a: type[AmbiguousClass],
    t: type[TruthyClass],
    f: type[FalsyClass],
    d: type[DeferredClass],
    ta: type[TruthyClass | AmbiguousClass],
    af: type[AmbiguousClass] | type[FalsyClass],
    flag: bool,
):
    reveal_type(ta)  # revealed: type[TruthyClass | AmbiguousClass]
    if ta:
        reveal_type(ta)  # revealed: type[TruthyClass] | (type[AmbiguousClass] & ~AlwaysFalsy)

    reveal_type(af)  # revealed: type[AmbiguousClass | FalsyClass]
    if af:
        reveal_type(af)  # revealed: type[AmbiguousClass] & ~AlwaysFalsy

    # error: [unsupported-bool-conversion] "Boolean conversion is not supported for type `MetaDeferred`"
    if d:
        # TODO: Should be `Unknown`
        reveal_type(d)  # revealed: type[DeferredClass] & ~AlwaysFalsy

    tf = TruthyClass if flag else FalsyClass
    reveal_type(tf)  # revealed: <class 'TruthyClass'> | <class 'FalsyClass'>

    if tf:
        reveal_type(tf)  # revealed: <class 'TruthyClass'>
    else:
        reveal_type(tf)  # revealed: <class 'FalsyClass'>

Narrowing in chained boolean expressions

py
from typing import Literal

class A: ...

def _(x: Literal[0, 1]):
    reveal_type(x or A())  # revealed: Literal[1] | A
    reveal_type(x and A())  # revealed: Literal[0] | A

def _(x: str):
    reveal_type(x or A())  # revealed: (str & ~AlwaysFalsy) | A
    reveal_type(x and A())  # revealed: (str & ~AlwaysTruthy) | A

def _(x: bool | str):
    reveal_type(x or A())  # revealed: Literal[True] | (str & ~AlwaysFalsy) | A
    reveal_type(x and A())  # revealed: Literal[False] | (str & ~AlwaysTruthy) | A

class Falsy:
    def __bool__(self) -> Literal[False]:
        return False

class Truthy:
    def __bool__(self) -> Literal[True]:
        return True

def _(x: Falsy | Truthy):
    reveal_type(x or A())  # revealed: Truthy | A
    reveal_type(x and A())  # revealed: Falsy | A

class MetaFalsy(type):
    def __bool__(self) -> Literal[False]:
        return False

class MetaTruthy(type):
    def __bool__(self) -> Literal[True]:
        return True

class FalsyClass(metaclass=MetaFalsy): ...
class TruthyClass(metaclass=MetaTruthy): ...

def _(x: type[FalsyClass] | type[TruthyClass]):
    reveal_type(x or A())  # revealed: type[TruthyClass] | A
    reveal_type(x and A())  # revealed: type[FalsyClass] | A

Narrowing with conditional expressions

py
def _(coinflip_1: bool, coinflip_2: bool):
    if coinflip_1 if coinflip_2 else coinflip_1:
        reveal_type(coinflip_1)  # revealed: Literal[True]
    else:
        reveal_type(coinflip_1)  # revealed: Literal[False]

Conditional expressions with ambiguous branch constraints

py
from typing import Literal

def _(flag: bool, x: Literal[0, 1], y: Literal[0, 1]):
    if x if flag else y:
        reveal_type(x)  # revealed: Literal[0, 1]
        reveal_type(y)  # revealed: Literal[0, 1]
    else:
        reveal_type(x)  # revealed: Literal[0, 1]
        reveal_type(y)  # revealed: Literal[0, 1]

def _(flag: bool, x: Literal[0, 1, 2]):
    if (x == 1) if flag else (x == 2):
        reveal_type(x)  # revealed: Literal[1, 2]
    else:
        reveal_type(x)  # revealed: Literal[0, 2, 1]

def _(flag: bool, x: Literal[0, 1], y: int):
    if (x == 1) if flag else y:
        reveal_type(x)  # revealed: Literal[0, 1]

Conditional expressions with statically known tests

py
from typing import Literal

def _(x: Literal[0, 1], y: Literal[0, 1]):
    if x if True else y:
        reveal_type(x)  # revealed: Literal[1]
    else:
        reveal_type(x)  # revealed: Literal[0]

    if x if False else y:
        reveal_type(y)  # revealed: Literal[1]
    else:
        reveal_type(y)  # revealed: Literal[0]

Truthiness narrowing for LiteralString

py
from typing_extensions import LiteralString

def _(x: LiteralString):
    if x:
        reveal_type(x)  # revealed: LiteralString & ~Literal[""]
    else:
        reveal_type(x)  # revealed: Literal[""]

    if not x:
        reveal_type(x)  # revealed: Literal[""]
    else:
        reveal_type(x)  # revealed: LiteralString & ~Literal[""]

Narrowing with named expressions (walrus operator)

When a truthiness check is used with a named expression, the target of the named expression should be narrowed.

py
def get_value() -> str | None:
    return "hello"

def f():
    if x := get_value():
        reveal_type(x)  # revealed: str & ~AlwaysFalsy
    else:
        reveal_type(x)  # revealed: (str & ~AlwaysTruthy) | None

Narrowing the value of a named expression

The value expression on the right-hand side of the walrus operator should also be narrowed:

py
def foo(value: int | None):
    if foo := value:
        reveal_type(value)  # revealed: int & ~AlwaysFalsy
    else:
        reveal_type(value)  # revealed: (int & ~AlwaysTruthy) | None

Narrowing a union of a TypedDict and None

py
from typing_extensions import TypedDict, NotRequired, Required

class Empty(TypedDict): ...

class NonEmpty(TypedDict):
    x: int

class HasNotRequired1(TypedDict):
    x: NotRequired[int]

class HasNotRequired2(TypedDict, total=False):
    x: int

class AlsoNonEmpty(TypedDict, total=False):
    x: Required[int]

def f(arg1: Empty | None, arg2: NonEmpty | None, arg3: HasNotRequired1 | None, arg4: HasNotRequired2 | None, arg5: AlsoNonEmpty):
    if arg1:
        # the truthiness of `Empty` is ambiguous,
        # because the `Empty` type includes possible `TypedDict` subtypes
        # that might have required keys
        reveal_type(arg1)  # revealed: Empty & ~AlwaysFalsy

    if arg2:
        # but `NonEmpty` is known to be a subtype of `AlwaysTruthy`
        # because of the required key, so we can narrow to a simpler type here
        reveal_type(arg2)  # revealed: NonEmpty

    if arg3:
        reveal_type(arg3)  # revealed: HasNotRequired1 & ~AlwaysFalsy

    if arg4:
        reveal_type(arg4)  # revealed: HasNotRequired2 & ~AlwaysFalsy

    if arg5:
        reveal_type(arg5)  # revealed: AlsoNonEmpty

When using a guard clause pattern (if not p: raise), the type should be narrowed in the continuation:

py
from typing import TypedDict

class Person(TypedDict, total=False):
    name: str
    age: int

def get_person() -> Person | None:
    return None

def test() -> None:
    p = get_person()
    if not p:
        raise ValueError("No person")
    reveal_type(p)  # revealed: Person & ~AlwaysFalsy
    # error: [invalid-key] "Unknown key "nonexistent" for TypedDict `Person`"
    print(p["nonexistent"])

Truthiness narrowing of NewTypes

py
from typing import NewType

FloatNewType = NewType("FloatNewType", float)
ComplexNewType = NewType("ComplexNewType", complex)

def expects_float(x: float): ...
def expects_complex(x: complex): ...
def f(floaty: FloatNewType, complexy: ComplexNewType):
    if floaty:
        reveal_type(floaty)  # revealed:FloatNewType & ~AlwaysFalsy
        expects_float(floaty)  # fine

    if complexy:
        reveal_type(complexy)  # revealed: ComplexNewType & ~AlwaysFalsy
        expects_complex(complexy)  # fine
        expects_float(complexy)  # error: [invalid-argument-type]