Skip to content
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,14 @@ reveal_type(my_bool(0)) # revealed: bool

## Truthy values

```toml
[environment]
python-version = "3.11"
```

```py
from typing import Literal

reveal_type(bool(1)) # revealed: Literal[True]
reveal_type(bool((0,))) # revealed: Literal[True]
reveal_type(bool("NON EMPTY")) # revealed: Literal[True]
Expand All @@ -81,6 +88,42 @@ reveal_type(bool(True)) # revealed: Literal[True]
def foo(): ...

reveal_type(bool(foo)) # revealed: Literal[True]

class SingleElementTupleSubclass(tuple[int]): ...

reveal_type(bool(SingleElementTupleSubclass((0,)))) # revealed: Literal[True]
reveal_type(SingleElementTupleSubclass.__bool__) # revealed: (self: tuple[int], /) -> Literal[True]
reveal_type(SingleElementTupleSubclass().__bool__) # revealed: () -> Literal[True]

# Unknown length, but we know the length is guaranteed to be >=2
class MixedTupleSubclass(tuple[int, *tuple[str, ...], bytes]): ...

reveal_type(bool(MixedTupleSubclass((1, b"foo")))) # revealed: Literal[True]
reveal_type(MixedTupleSubclass.__bool__) # revealed: (self: tuple[int, *tuple[str, ...], bytes], /) -> Literal[True]
reveal_type(MixedTupleSubclass().__bool__) # revealed: () -> Literal[True]

# Unknown length with an overridden `__bool__`:
class VariadicTupleSubclassWithDunderBoolOverride(tuple[int, ...]):
def __bool__(self) -> Literal[True]:
return True
Comment on lines +107 to +108
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should this not also be a Liskov violation?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Okay i guess its not a Liskov violation, but should we not complain at this?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ah sorry I retract this, my bad.

Copy link
Contributor

@MatthewMckee4 MatthewMckee4 Jul 11, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Okay changed my mind slightly

def foo(x: tuple[int, ...]):
    if x:
        # Should we not also be able to say len(x) != 0
        # *This should pass
        assert len(x) != 0

foo(Baz(()))

In rust if you have a len function clippy tells you to have an is_empty function and auto generates to self.len() == 0.

This maybe isn't relevant, but it seems like we shouldn't allow override of just __bool__. I'm not sure where else to go with this to be honest.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No, I think that's a great point! It seems like in general, as part of our Liskov implementation, we should probably enforce that __bool__ and __len__ are never overridden to be inconsistent with each other on any Sequence subtype. I think otherwise this could indeed cause us to make incorrect assumptions, not just regarding tuples

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cool thanks, so in this case we could emit a diagnostic saying that there should be a ’len’ method that returns ’int & ~Literal[0]’ (or something like that).

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hmm, not sure about that specifically -- I guess I'm not sure we should emit an error on this specific tuple subclass. It seems a bit pedantic -- just because the __len__ method is annotated as returning int doesn't mean that the class is violating the contract of Sequences. It just means that we haven't been given enough information to verify; I think it's probably better to steer clear of false positives in that situation and assume the user knows what they're doing. It's not the goal of ty to catch every possible error that a user could make.

But I do think it's worth emitting errors on these classes, because here we do have enough information to say that __len__ and __bool__ are inconsistent with each other:

from collections import Sequence
from typing import Literal

class Foo(Sequence[int]):
    def __len__(self) -> Literal[0]:
        return 0

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

class Bar(Sequence[int]):
    def __len__(self) -> Literal[3]:
        return 3

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

Even here, though, it's questionable whether this is a typing error or more something that's in the domain of a type-aware linter to check. (That doesn't mean that it wouldn't be a useful rule -- I think it would! But it might not be in the core purview of ty right now -- it might be more something for the future, when we start to use ty to power type-aware lint rules in Ruff.)

So I guess I feel like you're raising a great point in general, but that for these specific cases we probably shouldn't emit errors, and even if we should it maybe shouldn't be ty itself that emits these errors but maybe a type-aware linter built on top of ty :-)


reveal_type(bool(VariadicTupleSubclassWithDunderBoolOverride((1,)))) # revealed: Literal[True]
reveal_type(VariadicTupleSubclassWithDunderBoolOverride.__bool__) # revealed: def __bool__(self) -> Literal[True]

# revealed: bound method VariadicTupleSubclassWithDunderBoolOverride.__bool__() -> Literal[True]
reveal_type(VariadicTupleSubclassWithDunderBoolOverride().__bool__)

# Same again but for a subclass of a fixed-length tuple:
class EmptyTupleSubclassWithDunderBoolOverride(tuple[()]):
# TODO: we should reject this override as a Liskov violation:
def __bool__(self) -> Literal[True]:
return True

reveal_type(bool(EmptyTupleSubclassWithDunderBoolOverride(()))) # revealed: Literal[True]
reveal_type(EmptyTupleSubclassWithDunderBoolOverride.__bool__) # revealed: def __bool__(self) -> Literal[True]

# revealed: bound method EmptyTupleSubclassWithDunderBoolOverride.__bool__() -> Literal[True]
reveal_type(EmptyTupleSubclassWithDunderBoolOverride().__bool__)
```

## Falsy values
Expand All @@ -92,6 +135,12 @@ reveal_type(bool(None)) # revealed: Literal[False]
reveal_type(bool("")) # revealed: Literal[False]
reveal_type(bool(False)) # revealed: Literal[False]
reveal_type(bool()) # revealed: Literal[False]

class EmptyTupleSubclass(tuple[()]): ...

reveal_type(bool(EmptyTupleSubclass())) # revealed: Literal[False]
reveal_type(EmptyTupleSubclass.__bool__) # revealed: (self: tuple[()], /) -> Literal[False]
reveal_type(EmptyTupleSubclass().__bool__) # revealed: () -> Literal[False]
```

## Ambiguous values
Expand All @@ -100,6 +149,13 @@ reveal_type(bool()) # revealed: Literal[False]
reveal_type(bool([])) # revealed: bool
reveal_type(bool({})) # revealed: bool
reveal_type(bool(set())) # revealed: bool

class VariadicTupleSubclass(tuple[int, ...]): ...

def f(x: tuple[int, ...], y: VariadicTupleSubclass):
reveal_type(bool(x)) # revealed: bool
reveal_type(x.__bool__) # revealed: () -> bool
reveal_type(y.__bool__) # revealed: () -> bool
```

## `__bool__` returning `NoReturn`
Expand Down
45 changes: 45 additions & 0 deletions crates/ty_python_semantic/resources/mdtest/expression/len.md
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,51 @@ reveal_type(len((*[], 1, 2))) # revealed: Literal[3]
reveal_type(len((*[], *{}))) # revealed: Literal[2]
```

Tuple subclasses:

```py
class EmptyTupleSubclass(tuple[()]): ...
class Length1TupleSubclass(tuple[int]): ...
class Length2TupleSubclass(tuple[int, str]): ...
class UnknownLengthTupleSubclass(tuple[int, ...]): ...

reveal_type(len(EmptyTupleSubclass())) # revealed: Literal[0]
reveal_type(len(Length1TupleSubclass((1,)))) # revealed: Literal[1]
reveal_type(len(Length2TupleSubclass((1, "foo")))) # revealed: Literal[2]
reveal_type(len(UnknownLengthTupleSubclass((1, 2, 3)))) # revealed: int

reveal_type(tuple[int, int].__len__) # revealed: (self: tuple[int, int], /) -> Literal[2]
reveal_type(tuple[int, ...].__len__) # revealed: (self: tuple[int, ...], /) -> int

def f(x: tuple[int, int], y: tuple[int, ...]):
reveal_type(x.__len__) # revealed: () -> Literal[2]
reveal_type(y.__len__) # revealed: () -> int

reveal_type(EmptyTupleSubclass.__len__) # revealed: (self: tuple[()], /) -> Literal[0]
reveal_type(EmptyTupleSubclass().__len__) # revealed: () -> Literal[0]
reveal_type(UnknownLengthTupleSubclass.__len__) # revealed: (self: tuple[int, ...], /) -> int
reveal_type(UnknownLengthTupleSubclass().__len__) # revealed: () -> int
```

If `__len__` is overridden, we use the overridden return type:

```py
from typing import Literal

class UnknownLengthSubclassWithDunderLenOverridden(tuple[int, ...]):
def __len__(self) -> Literal[42]:
return 42

reveal_type(len(UnknownLengthSubclassWithDunderLenOverridden())) # revealed: Literal[42]

class FixedLengthSubclassWithDunderLenOverridden(tuple[int]):
# TODO: we should complain about this as a Liskov violation (incompatible override)
def __len__(self) -> Literal[42]:
return 42

reveal_type(len(FixedLengthSubclassWithDunderLenOverridden((1,)))) # revealed: Literal[42]
```

### Lists, sets and dictionaries

```py
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -551,6 +551,11 @@ static_assert(is_subtype_of(Never, AlwaysFalsy))

### `AlwaysTruthy` and `AlwaysFalsy`

```toml
[environment]
python-version = "3.11"
```

```py
from ty_extensions import AlwaysTruthy, AlwaysFalsy, Intersection, Not, is_subtype_of, static_assert
from typing_extensions import Literal, LiteralString
Expand Down Expand Up @@ -588,6 +593,30 @@ static_assert(is_subtype_of(Intersection[LiteralString, Not[Literal["", "a"]]],
static_assert(is_subtype_of(Intersection[LiteralString, Not[Literal[""]]], Not[AlwaysFalsy]))
# error: [static-assert-error]
static_assert(is_subtype_of(Intersection[LiteralString, Not[Literal["", "a"]]], Not[AlwaysFalsy]))

class Length2TupleSubclass(tuple[int, str]): ...

static_assert(is_subtype_of(Length2TupleSubclass, AlwaysTruthy))

class EmptyTupleSubclass(tuple[()]): ...

static_assert(is_subtype_of(EmptyTupleSubclass, AlwaysFalsy))

class TupleSubclassWithAtLeastLength2(tuple[int, *tuple[str, ...], bytes]): ...

static_assert(is_subtype_of(TupleSubclassWithAtLeastLength2, AlwaysTruthy))

class UnknownLength(tuple[int, ...]): ...

static_assert(not is_subtype_of(UnknownLength, AlwaysTruthy))
static_assert(not is_subtype_of(UnknownLength, AlwaysFalsy))

class Invalid(tuple[int, str]):
# TODO: we should emit an error here (Liskov violation)
def __bool__(self) -> Literal[False]:
return False

static_assert(is_subtype_of(Invalid, AlwaysFalsy))
```

### `TypeGuard` and `TypeIs`
Expand Down
21 changes: 8 additions & 13 deletions crates/ty_python_semantic/src/types.rs
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,7 @@ use crate::types::infer::infer_unpack_types;
use crate::types::mro::{Mro, MroError, MroIterator};
pub(crate) use crate::types::narrow::infer_narrowing_constraint;
use crate::types::signatures::{Parameter, ParameterForm, Parameters, walk_signature};
use crate::types::tuple::{TupleSpec, TupleType};
use crate::types::tuple::TupleType;
pub use crate::util::diagnostics::add_inferred_python_version_hint_to_diagnostic;
use crate::{Db, FxOrderSet, Module, Program};
pub(crate) use class::{ClassLiteral, ClassType, GenericAlias, KnownClass};
Expand Down Expand Up @@ -3508,14 +3508,7 @@ impl<'db> Type<'db> {
Type::BooleanLiteral(bool) => Truthiness::from(*bool),
Type::StringLiteral(str) => Truthiness::from(!str.value(db).is_empty()),
Type::BytesLiteral(bytes) => Truthiness::from(!bytes.value(db).is_empty()),
Type::Tuple(tuple) => match tuple.tuple(db).len().size_hint() {
// The tuple type is AlwaysFalse if it contains only the empty tuple
(_, Some(0)) => Truthiness::AlwaysFalse,
// The tuple type is AlwaysTrue if its inhabitants must always have length >=1
(minimum, _) if minimum > 0 => Truthiness::AlwaysTrue,
// The tuple type is Ambiguous if its inhabitants could be of any length
_ => Truthiness::Ambiguous,
},
Type::Tuple(tuple) => tuple.truthiness(db),
};

Ok(truthiness)
Expand All @@ -3542,10 +3535,12 @@ impl<'db> Type<'db> {
let usize_len = match self {
Type::BytesLiteral(bytes) => Some(bytes.python_len(db)),
Type::StringLiteral(string) => Some(string.python_len(db)),
Type::Tuple(tuple) => match tuple.tuple(db) {
TupleSpec::Fixed(tuple) => Some(tuple.len()),
TupleSpec::Variable(_) => None,
},

// N.B. This is strictly-speaking redundant, since the `__len__` method on tuples
// is special-cased in `ClassType::own_class_member`. However, it's probably more
// efficient to short-circuit here and check against the tuple spec directly,
// rather than going through the `__len__` method.
Type::Tuple(tuple) => tuple.tuple(db).len().into_fixed_length(),

_ => None,
};
Expand Down
36 changes: 33 additions & 3 deletions crates/ty_python_semantic/src/types/class.rs
Original file line number Diff line number Diff line change
Expand Up @@ -574,9 +574,39 @@ impl<'db> ClassType<'db> {
/// traverse through the MRO until it finds the member.
pub(super) fn own_class_member(self, db: &'db dyn Db, name: &str) -> PlaceAndQualifiers<'db> {
let (class_literal, specialization) = self.class_literal(db);
class_literal
.own_class_member(db, specialization, name)
.map_type(|ty| ty.apply_optional_specialization(db, specialization))

let synthesize_tuple_method = |return_type| {
let parameters =
Parameters::new([Parameter::positional_only(Some(Name::new_static("self")))
.with_annotated_type(Type::instance(db, self))]);

let synthesized_dunder_method =
CallableType::function_like(db, Signature::new(parameters, Some(return_type)));

Place::bound(synthesized_dunder_method).into()
};

match name {
"__len__" if class_literal.is_known(db, KnownClass::Tuple) => {
let return_type = specialization
.and_then(|spec| spec.tuple(db).len().into_fixed_length())
.and_then(|len| i64::try_from(len).ok())
.map(Type::IntLiteral)
.unwrap_or_else(|| KnownClass::Int.to_instance(db));

synthesize_tuple_method(return_type)
}
"__bool__" if class_literal.is_known(db, KnownClass::Tuple) => {
let return_type = specialization
.map(|spec| spec.tuple(db).truthiness().into_type(db))
.unwrap_or_else(|| KnownClass::Bool.to_instance(db));

synthesize_tuple_method(return_type)
}
_ => class_literal
.own_class_member(db, specialization, name)
.map_type(|ty| ty.apply_optional_specialization(db, specialization)),
}
}

/// Look up an instance attribute (available in `__dict__`) of the given name.
Expand Down
23 changes: 23 additions & 0 deletions crates/ty_python_semantic/src/types/tuple.rs
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ use std::hash::Hash;

use itertools::{Either, EitherOrBoth, Itertools};

use crate::types::Truthiness;
use crate::types::class::{ClassType, KnownClass};
use crate::types::{
Type, TypeMapping, TypeRelation, TypeTransformer, TypeVarInstance, TypeVarVariance,
Expand Down Expand Up @@ -76,6 +77,13 @@ impl TupleLength {
None => "unlimited".to_string(),
}
}

pub(crate) fn into_fixed_length(self) -> Option<usize> {
match self {
TupleLength::Fixed(len) => Some(len),
TupleLength::Variable(_, _) => None,
}
}
}

/// # Ordering
Expand Down Expand Up @@ -240,6 +248,10 @@ impl<'db> TupleType<'db> {
pub(crate) fn is_single_valued(self, db: &'db dyn Db) -> bool {
self.tuple(db).is_single_valued(db)
}

pub(crate) fn truthiness(self, db: &'db dyn Db) -> Truthiness {
self.tuple(db).truthiness()
}
}

/// A tuple spec describes the contents of a tuple type, which might be fixed- or variable-length.
Expand Down Expand Up @@ -967,6 +979,17 @@ impl<T> Tuple<T> {
}
}

pub(crate) fn truthiness(&self) -> Truthiness {
match self.len().size_hint() {
// The tuple type is AlwaysFalse if it contains only the empty tuple
(_, Some(0)) => Truthiness::AlwaysFalse,
// The tuple type is AlwaysTrue if its inhabitants must always have length >=1
(minimum, _) if minimum > 0 => Truthiness::AlwaysTrue,
// The tuple type is Ambiguous if its inhabitants could be of any length
_ => Truthiness::Ambiguous,
}
}

pub(crate) fn is_empty(&self) -> bool {
match self {
Tuple::Fixed(tuple) => tuple.is_empty(),
Expand Down
Loading