crates/ty_python_semantic/resources/mdtest/narrow/len.md
len(..) checksWhen len(x) is used in a boolean context, we can narrow the type of x based on whether len(x)
is truthy (non-zero) or falsy (zero).
We apply ~AlwaysFalsy narrowing when ANY part of the type is narrowable (string/bytes literals,
LiteralString, tuples). This removes types that are always falsy (like Literal[""]) while
leaving non-narrowable types (like str, list) unchanged.
The intersection with ~AlwaysFalsy simplifies to just the non-empty literal.
from typing import Literal
def _(x: Literal["foo", ""]):
if len(x):
reveal_type(x) # revealed: Literal["foo"]
else:
reveal_type(x) # revealed: Literal[""]
from typing import Literal
def _(x: Literal[b"foo", b""]):
if len(x):
reveal_type(x) # revealed: Literal[b"foo"]
else:
reveal_type(x) # revealed: Literal[b""]
[environment]
python-version = "3.11"
from typing import LiteralString
def _(x: LiteralString):
if len(x):
reveal_type(x) # revealed: LiteralString & ~Literal[""]
else:
reveal_type(x) # revealed: Literal[""]
Ideally we'd narrow these types further, e.g. to tuple[int, ...] & ~tuple[()] in the positive case
and tuple[()] in the negative case (see https://github.com/astral-sh/ty/issues/560).
def _(x: tuple[int, ...]):
if len(x):
reveal_type(x) # revealed: tuple[int, ...] & ~AlwaysFalsy
else:
reveal_type(x) # revealed: tuple[int, ...] & ~AlwaysTruthy
Exact length constraints eliminate incompatible fixed-length tuples. Variable-length tuples remain
intersected with ExactlySized until tuple/protocol intersections can be simplified generally:
[environment]
python-version = "3.11"
from typing import Literal
def _(val: tuple[int] | tuple[str, str] | tuple[int, *tuple[str, ...], int]):
if len(val) == 1:
# revealed: tuple[int] | (tuple[int, *tuple[str, ...], int] & ExactlySized[Literal[1, True]])
reveal_type(val)
if len(val) == 2:
# revealed: tuple[str, str] | (tuple[int, *tuple[str, ...], int] & ExactlySized[Literal[2]])
reveal_type(val)
if len(val) == 3:
# revealed: tuple[int, *tuple[str, ...], int] & ExactlySized[Literal[3]]
reveal_type(val)
def _(val: tuple[int] | tuple[str, str]):
if 1 != len(val):
reveal_type(val) # revealed: tuple[str, str]
else:
reveal_type(val) # revealed: tuple[int]
def _(val: tuple[int, ...]):
if val and len(val) == 2:
reveal_type(val) # revealed: tuple[int, ...] & ExactlySized[Literal[2]] & ~AlwaysFalsy
def _(val: tuple[int] | tuple[str, str]):
if len(val) == True:
reveal_type(val) # revealed: tuple[int]
one: tuple[int] = val
def _(val: tuple[()] | tuple[int]):
if False == len(val):
reveal_type(val) # revealed: tuple[()]
empty: tuple[()] = val
def _(x: Literal[b"", b"a"], y: Literal["a", "ab"]):
if len(x) == 1:
reveal_type(x) # revealed: Literal[b"a"]
else:
reveal_type(x) # revealed: Literal[b""]
if len(y) == 1:
reveal_type(y) # revealed: Literal["a"]
else:
reveal_type(y) # revealed: Literal["ab"]
Exact length comparisons intersect arbitrary Sized values with ExactlySized. This persists the
observed length even for mutable or stateful values, consistent with other forms of narrowing:
from typing import Literal, Sized
class LengthThree:
def __len__(self) -> Literal[3]:
return 3
class LengthFour:
def __len__(self) -> Literal[4]:
return 4
def _(value: LengthThree | LengthFour):
if len(value) == 3:
reveal_type(value) # revealed: LengthThree & ExactlySized[Literal[3]]
else:
reveal_type(value) # revealed: LengthFour
class TrueLength:
def __len__(self) -> Literal[True]:
return True
class FalseLength:
def __len__(self) -> Literal[False]:
return False
def _(value: TrueLength | FalseLength):
if len(value) == 1:
reveal_type(value) # revealed: TrueLength & ExactlySized[Literal[1, True]]
else:
reveal_type(value) # revealed: FalseLength
def _(value: LengthThree | list[int]):
if len(value) == 3:
# revealed: (LengthThree & ExactlySized[Literal[3]]) | (list[int] & ExactlySized[Literal[3]])
reveal_type(value)
else:
reveal_type(value) # revealed: list[int] & ~ExactlySized[Literal[3]]
The length constraint remains after mutation. This is an accepted limitation:
def _(value: Sized):
if len(value) == 3:
reveal_type(value) # revealed: ExactlySized[Literal[3]]
reveal_type(len(value)) # revealed: Literal[3]
else:
reveal_type(value) # revealed: Sized & ~ExactlySized[Literal[3]]
def _(items: list[int]):
if len(items) == 3:
reveal_type(items) # revealed: list[int] & ExactlySized[Literal[3]]
items.clear()
reveal_type(len(items)) # revealed: Literal[3]
PEP 695 aliases are resolved before extracting literal lengths:
[environment]
python-version = "3.12"
from typing import Literal
type Two = Literal[2]
def _(val: tuple[int, ...], n: Two):
if len(val) == n:
reveal_type(val) # revealed: tuple[int, ...] & ExactlySized[Literal[2]]
from typing import Literal
def _(x: Literal["foo", ""] | tuple[int, ...]):
if len(x):
reveal_type(x) # revealed: Literal["foo"] | (tuple[int, ...] & ~AlwaysFalsy)
else:
reveal_type(x) # revealed: Literal[""] | (tuple[int, ...] & ~AlwaysTruthy)
If a custom type defines a __len__ method and a __bool__ method, and both return Literal
types, and the truthiness of the __len__ return type is consistent with the truthiness of the
__bool__ return type, narrowing can still safely take place:
from typing import Literal
class Foo:
def __bool__(self) -> Literal[True]:
return True
def __len__(self) -> Literal[42]:
return 42
class Bar:
def __bool__(self) -> Literal[False]:
return False
def __len__(self) -> Literal[0]:
return 0
class Inconsistent1:
def __bool__(self) -> Literal[True]:
return True
def __len__(self) -> Literal[0]:
return 0
class Inconsistent2:
def __bool__(self) -> Literal[False]:
return False
def __len__(self) -> Literal[42]:
return 42
def f(
a: Foo | list[int],
b: Bar | list[int],
c: Foo | Bar,
d: Inconsistent1 | list[int],
e: Inconsistent2 | list[int],
):
if len(a):
reveal_type(a) # revealed: Foo | list[int]
else:
reveal_type(a) # revealed: list[int]
if not len(a):
reveal_type(a) # revealed: list[int]
else:
reveal_type(a) # revealed: Foo | list[int]
if len(b):
reveal_type(b) # revealed: list[int]
else:
reveal_type(b) # revealed: Bar | list[int]
if not len(b):
reveal_type(b) # revealed: Bar | list[int]
else:
reveal_type(b) # revealed: list[int]
if len(c):
reveal_type(c) # revealed: Foo
else:
reveal_type(c) # revealed: Bar
# No narrowing can take place for `d` or `e`,
# because the `__len__` and `__bool__` methods are inconsistent
# for both `Inconsistent1` and `Inconsistent2`.
if len(d):
reveal_type(d) # revealed: Inconsistent1 | list[int]
else:
reveal_type(d) # revealed: Inconsistent1 | list[int]
if len(e):
reveal_type(e) # revealed: Inconsistent2 | list[int]
else:
reveal_type(e) # revealed: Inconsistent2 | list[int]
For str, list, and other types where a subclass could have a __bool__ that disagrees with
__len__, we do not narrow:
def not_narrowed_str(x: str):
if len(x):
# No narrowing because `str` could be subclassed with a custom `__bool__`
reveal_type(x) # revealed: str
def not_narrowed_list(x: list[int]):
if len(x):
# No narrowing because `list` could be subclassed with a custom `__bool__`
reveal_type(x) # revealed: list[int]
When a union contains both narrowable and non-narrowable types, we narrow the narrowable parts while leaving the non-narrowable parts unchanged:
from typing import Literal
def _(x: Literal["foo", ""] | list[int]):
if len(x):
# `Literal[""]` is removed, `list[int]` is unchanged
reveal_type(x) # revealed: Literal["foo"] | list[int]
else:
reveal_type(x) # revealed: Literal[""] | list[int]
This pattern is common when a prior truthiness check narrows a type, and then a conditional expression adds an empty literal back:
def _(lines: list[str]):
for line in lines:
if not line:
continue
reveal_type(line) # revealed: str & ~AlwaysFalsy
value = line if len(line) < 3 else ""
reveal_type(value) # revealed: (str & ~AlwaysFalsy) | Literal[""]
if len(value):
# `Literal[""]` is removed, `str & ~AlwaysFalsy` is unchanged
reveal_type(value) # revealed: str & ~AlwaysFalsy
# Accessing value[0] is safe here
_ = value[0]