Skip to content

Commit 4c7d1f5

Browse files
authored
[ty] Infer TypedDict types with >=1 required key as being always truthy (#22808)
1 parent b7de434 commit 4c7d1f5

File tree

3 files changed

+53
-8
lines changed

3 files changed

+53
-8
lines changed

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

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -363,3 +363,44 @@ def f():
363363
else:
364364
reveal_type(x) # revealed: (str & ~AlwaysTruthy) | None
365365
```
366+
367+
## Narrowing a union of a `TypedDict` and `None`
368+
369+
```py
370+
from typing_extensions import TypedDict, NotRequired, Required
371+
372+
class Empty(TypedDict): ...
373+
374+
class NonEmpty(TypedDict):
375+
x: int
376+
377+
class HasNotRequired1(TypedDict):
378+
x: NotRequired[int]
379+
380+
class HasNotRequired2(TypedDict, total=False):
381+
x: int
382+
383+
class AlsoNonEmpty(TypedDict, total=False):
384+
x: Required[int]
385+
386+
def f(arg1: Empty | None, arg2: NonEmpty | None, arg3: HasNotRequired1 | None, arg4: HasNotRequired2 | None, arg5: AlsoNonEmpty):
387+
if arg1:
388+
# the truthiness of `Empty` is ambiguous,
389+
# because the `Empty` type includes possible `TypedDict` subtypes
390+
# that might have required keys
391+
reveal_type(arg1) # revealed: Empty & ~AlwaysFalsy
392+
393+
if arg2:
394+
# but `NonEmpty` is known to be a subtype of `AlwaysTruthy`
395+
# because of the required key, so we can narrow to a simpler type here
396+
reveal_type(arg2) # revealed: NonEmpty
397+
398+
if arg3:
399+
reveal_type(arg3) # revealed: HasNotRequired1 & ~AlwaysFalsy
400+
401+
if arg4:
402+
reveal_type(arg4) # revealed: HasNotRequired2 & ~AlwaysFalsy
403+
404+
if arg5:
405+
reveal_type(arg5) # revealed: AlsoNonEmpty
406+
```

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

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -221,15 +221,14 @@ class Normal(TypedDict):
221221
b: int
222222

223223
def _(n: Normal) -> None:
224-
# Could be `Literal[True]`
225-
reveal_type(bool(n)) # revealed: bool
224+
reveal_type(bool(n)) # revealed: Literal[True]
226225

227226
class OnlyFalsyItems(TypedDict):
228227
wrong: Literal[False]
229228

230229
def _(n: OnlyFalsyItems) -> None:
231-
# Could be `Literal[True]` (it does not matter if all items are falsy)
232-
reveal_type(bool(n)) # revealed: bool
230+
# (it does not matter if all items are falsy)
231+
reveal_type(bool(n)) # revealed: Literal[True]
233232

234233
class Empty(TypedDict):
235234
pass

crates/ty_python_semantic/src/types.rs

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,7 @@ use crate::types::newtype::NewType;
7171
pub(crate) use crate::types::signatures::{Parameter, Parameters};
7272
use crate::types::signatures::{ParameterForm, walk_signature};
7373
use crate::types::tuple::{Tuple, TupleSpec, TupleSpecBuilder};
74+
use crate::types::typed_dict::TypedDictField;
7475
pub(crate) use crate::types::typed_dict::{TypedDictParams, TypedDictType, walk_typed_dict_type};
7576
pub use crate::types::variance::TypeVarVariance;
7677
use crate::types::variance::VarianceInferable;
@@ -3658,10 +3659,14 @@ impl<'db> Type<'db> {
36583659
| Type::TypeIs(_)
36593660
| Type::TypeGuard(_) => Truthiness::Ambiguous,
36603661

3661-
Type::TypedDict(_) => {
3662-
// TODO: We could do better here, but it's unclear if this is important.
3663-
// See existing `TypedDict`-related tests in `truthiness.md`
3664-
Truthiness::Ambiguous
3662+
Type::TypedDict(td) => {
3663+
if td.items(db).values().any(TypedDictField::is_required) {
3664+
Truthiness::AlwaysTrue
3665+
} else {
3666+
// We can potentially infer empty typeddicts as always falsy if they're `closed=True`,
3667+
// but as of 22-01-26 we don't yet support PEP 728.
3668+
Truthiness::Ambiguous
3669+
}
36653670
}
36663671

36673672
Type::KnownInstance(KnownInstanceType::ConstraintSet(tracked_set)) => {

0 commit comments

Comments
 (0)