Skip to content

Commit 2bda074

Browse files
committed
[ty] Ensuring TypedDict subscripts for unknown keys return Unknown
1 parent 02f81b1 commit 2bda074

2 files changed

Lines changed: 70 additions & 4 deletions

File tree

crates/ty_python_semantic/resources/mdtest/typed_dict.md

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1414,6 +1414,7 @@ AGE_FINAL: Final[Literal["age"]] = "age"
14141414

14151415
def _(
14161416
person: Person,
1417+
animal: Animal,
14171418
being: Person | Animal,
14181419
literal_key: Literal["age"],
14191420
union_of_keys: Literal["age", "name"],
@@ -1439,12 +1440,13 @@ def _(
14391440
# No error here:
14401441
reveal_type(person[unknown_key]) # revealed: Unknown
14411442

1443+
# error: [invalid-key] "Unknown key "anything" for TypedDict `Animal`"
1444+
reveal_type(animal["anything"]) # revealed: Unknown
1445+
14421446
reveal_type(being["name"]) # revealed: str
14431447

1444-
# TODO: A type of `int | None | Unknown` might be better here. The `str` is mixed in
1445-
# because `Animal.__getitem__` can only return `str`.
14461448
# error: [invalid-key] "Unknown key "age" for TypedDict `Animal`"
1447-
reveal_type(being["age"]) # revealed: int | None | str
1449+
reveal_type(being["age"]) # revealed: int | None | Unknown
14481450
```
14491451

14501452
### Writing

crates/ty_python_semantic/src/types/subscript.rs

Lines changed: 65 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ use super::special_form::SpecialFormType;
2323
use super::tuple::TupleSpec;
2424
use super::{
2525
DynamicType, IntersectionBuilder, IntersectionType, KnownInstanceType, Type, TypeAliasType,
26-
UnionBuilder, UnionType, todo_type,
26+
TypedDictType, UnionBuilder, UnionType, todo_type,
2727
};
2828

2929
/// The kind of subscriptable type that had an out-of-bounds index.
@@ -120,6 +120,11 @@ pub(crate) enum SubscriptErrorKind<'db> {
120120
kind: CallErrorKind,
121121
bindings: Box<Bindings<'db>>,
122122
},
123+
/// A `TypedDict` was subscripted with an invalid key.
124+
InvalidTypedDictKey {
125+
typed_dict: TypedDictType<'db>,
126+
slice_ty: Type<'db>,
127+
},
123128
/// The type does not support subscripting via the expected dunder.
124129
NotSubscriptable {
125130
value_ty: Type<'db>,
@@ -280,6 +285,21 @@ impl<'db> SubscriptErrorKind<'db> {
280285
}
281286
}
282287
},
288+
Self::InvalidTypedDictKey {
289+
typed_dict,
290+
slice_ty,
291+
} => {
292+
let typed_dict_ty = Type::TypedDict(*typed_dict);
293+
report_invalid_key_on_typed_dict(
294+
context,
295+
value_node.into(),
296+
slice_node.into(),
297+
typed_dict_ty,
298+
None,
299+
*slice_ty,
300+
typed_dict.items(db),
301+
);
302+
}
283303
Self::NotSubscriptable { value_ty, method } => {
284304
report_not_subscriptable(context, subscript, *value_ty, method.as_str());
285305
}
@@ -411,6 +431,45 @@ where
411431
))
412432
}
413433

434+
// `TypedDict` subscripts need custom handling because invalid keys should still
435+
// recover with `Unknown` while emitting `invalid-key`, which is not naturally
436+
// representable via synthesized `__getitem__` overloads alone.
437+
fn typed_dict_subscript<'db>(
438+
db: &'db dyn Db,
439+
typed_dict: TypedDictType<'db>,
440+
slice_ty: Type<'db>,
441+
) -> Result<Type<'db>, SubscriptError<'db>> {
442+
if slice_ty.is_dynamic() {
443+
return Ok(Type::unknown());
444+
}
445+
446+
let Some(key) = slice_ty
447+
.as_string_literal()
448+
.map(|literal| literal.value(db))
449+
else {
450+
return Err(SubscriptError::new(
451+
Type::unknown(),
452+
SubscriptErrorKind::InvalidTypedDictKey {
453+
typed_dict,
454+
slice_ty,
455+
},
456+
));
457+
};
458+
459+
typed_dict.items(db).get(key).map_or_else(
460+
|| {
461+
Err(SubscriptError::new(
462+
Type::unknown(),
463+
SubscriptErrorKind::InvalidTypedDictKey {
464+
typed_dict,
465+
slice_ty,
466+
},
467+
))
468+
},
469+
|field| Ok(field.declared_ty),
470+
)
471+
}
472+
414473
impl<'db> Type<'db> {
415474
pub(super) fn subscript(
416475
self,
@@ -451,6 +510,11 @@ impl<'db> Type<'db> {
451510
}))
452511
}
453512

513+
// Ex) Given `person["name"]`, return `str`
514+
(Type::TypedDict(typed_dict), _) if expr_context != ast::ExprContext::Store => {
515+
Some(typed_dict_subscript(db, typed_dict, slice_ty))
516+
}
517+
454518
// Ex) Given `("a", "b", "c", "d")[1]`, return `"b"`
455519
(Type::NominalInstance(nominal), Type::LiteralValue(literal)) if literal.is_int() => {
456520
let i64_int = literal.as_int().unwrap();

0 commit comments

Comments
 (0)