Skip to content

Commit 3c8fb68

Browse files
authored
[ty] dict is not assignable to TypedDict (#21238)
## Summary A lot of the bidirectional inference work relies on `dict` not being assignable to `TypedDict`, so I think it makes sense to add this before fully implementing astral-sh/ty#1387.
1 parent 42adfd4 commit 3c8fb68

File tree

11 files changed

+169
-75
lines changed

11 files changed

+169
-75
lines changed

crates/ty_python_semantic/resources/mdtest/bidirectional.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,7 @@ def _() -> TD:
7676

7777
def _() -> TD:
7878
# error: [missing-typed-dict-key] "Missing required key 'x' in TypedDict `TD` constructor"
79+
# error: [invalid-return-type]
7980
return {}
8081
```
8182

crates/ty_python_semantic/resources/mdtest/call/overloads.md

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1685,8 +1685,7 @@ def int_or_str() -> int | str:
16851685
x = f([{"x": 1}], int_or_str())
16861686
reveal_type(x) # revealed: int | str
16871687

1688-
# TODO: error: [no-matching-overload] "No overload of function `f` matches arguments"
1689-
# we currently incorrectly consider `list[dict[str, int]]` a subtype of `list[T]`
1688+
# error: [no-matching-overload] "No overload of function `f` matches arguments"
16901689
f([{"y": 1}], int_or_str())
16911690
```
16921691

crates/ty_python_semantic/resources/mdtest/call/union.md

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -277,7 +277,6 @@ def _(flag: bool):
277277
x = f({"x": 1})
278278
reveal_type(x) # revealed: int
279279

280-
# TODO: error: [invalid-argument-type] "Argument to function `f` is incorrect: Expected `T`, found `dict[str, int]`"
281-
# we currently consider `TypedDict` instances to be subtypes of `dict`
280+
# error: [invalid-argument-type] "Argument to function `f` is incorrect: Expected `T`, found `dict[Unknown | str, Unknown | int]`"
282281
f({"y": 1})
283282
```

crates/ty_python_semantic/resources/mdtest/comprehensions/basic.md

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -162,10 +162,13 @@ The type context is propagated down into the comprehension:
162162
class Person(TypedDict):
163163
name: str
164164

165+
# TODO: This should not error.
166+
# error: [invalid-assignment]
165167
persons: list[Person] = [{"name": n} for n in ["Alice", "Bob"]]
166168
reveal_type(persons) # revealed: list[Person]
167169

168-
# TODO: This should be an error
170+
# TODO: This should be an invalid-key error.
171+
# error: [invalid-assignment]
169172
invalid: list[Person] = [{"misspelled": n} for n in ["Alice", "Bob"]]
170173
```
171174

crates/ty_python_semantic/resources/mdtest/snapshots/typed_dict.md_-_`TypedDict`_-_Diagnostics_(e5289abf5c570c29).snap

Lines changed: 29 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -39,16 +39,19 @@ mdtest path: crates/ty_python_semantic/resources/mdtest/typed_dict.md
3939
25 | person[str_key] = "Alice" # error: [invalid-key]
4040
26 |
4141
27 | def create_with_invalid_string_key():
42-
28 | alice: Person = {"name": "Alice", "age": 30, "unknown": "Foo"} # error: [invalid-key]
43-
29 | bob = Person(name="Bob", age=25, unknown="Bar") # error: [invalid-key]
44-
30 | from typing_extensions import ReadOnly
45-
31 |
46-
32 | class Employee(TypedDict):
47-
33 | id: ReadOnly[int]
48-
34 | name: str
49-
35 |
50-
36 | def write_to_readonly_key(employee: Employee):
51-
37 | employee["id"] = 42 # error: [invalid-assignment]
42+
28 | # error: [invalid-key]
43+
29 | alice: Person = {"name": "Alice", "age": 30, "unknown": "Foo"}
44+
30 |
45+
31 | # error: [invalid-key]
46+
32 | bob = Person(name="Bob", age=25, unknown="Bar")
47+
33 | from typing_extensions import ReadOnly
48+
34 |
49+
35 | class Employee(TypedDict):
50+
36 | id: ReadOnly[int]
51+
37 | name: str
52+
38 |
53+
39 | def write_to_readonly_key(employee: Employee):
54+
40 | employee["id"] = 42 # error: [invalid-assignment]
5255
```
5356

5457
# Diagnostics
@@ -158,52 +161,52 @@ info: rule `invalid-key` is enabled by default
158161

159162
```
160163
error[invalid-key]: Invalid key for TypedDict `Person`
161-
--> src/mdtest_snippet.py:28:21
164+
--> src/mdtest_snippet.py:29:21
162165
|
163166
27 | def create_with_invalid_string_key():
164-
28 | alice: Person = {"name": "Alice", "age": 30, "unknown": "Foo"} # error: [invalid-key]
167+
28 | # error: [invalid-key]
168+
29 | alice: Person = {"name": "Alice", "age": 30, "unknown": "Foo"}
165169
| -----------------------------^^^^^^^^^--------
166170
| | |
167171
| | Unknown key "unknown"
168172
| TypedDict `Person`
169-
29 | bob = Person(name="Bob", age=25, unknown="Bar") # error: [invalid-key]
170-
30 | from typing_extensions import ReadOnly
173+
30 |
174+
31 | # error: [invalid-key]
171175
|
172176
info: rule `invalid-key` is enabled by default
173177
174178
```
175179

176180
```
177181
error[invalid-key]: Invalid key for TypedDict `Person`
178-
--> src/mdtest_snippet.py:29:11
182+
--> src/mdtest_snippet.py:32:11
179183
|
180-
27 | def create_with_invalid_string_key():
181-
28 | alice: Person = {"name": "Alice", "age": 30, "unknown": "Foo"} # error: [invalid-key]
182-
29 | bob = Person(name="Bob", age=25, unknown="Bar") # error: [invalid-key]
184+
31 | # error: [invalid-key]
185+
32 | bob = Person(name="Bob", age=25, unknown="Bar")
183186
| ------ TypedDict `Person` ^^^^^^^^^^^^^ Unknown key "unknown"
184-
30 | from typing_extensions import ReadOnly
187+
33 | from typing_extensions import ReadOnly
185188
|
186189
info: rule `invalid-key` is enabled by default
187190
188191
```
189192

190193
```
191194
error[invalid-assignment]: Cannot assign to key "id" on TypedDict `Employee`
192-
--> src/mdtest_snippet.py:37:5
195+
--> src/mdtest_snippet.py:40:5
193196
|
194-
36 | def write_to_readonly_key(employee: Employee):
195-
37 | employee["id"] = 42 # error: [invalid-assignment]
197+
39 | def write_to_readonly_key(employee: Employee):
198+
40 | employee["id"] = 42 # error: [invalid-assignment]
196199
| -------- ^^^^ key is marked read-only
197200
| |
198201
| TypedDict `Employee`
199202
|
200203
info: Item declaration
201-
--> src/mdtest_snippet.py:33:5
204+
--> src/mdtest_snippet.py:36:5
202205
|
203-
32 | class Employee(TypedDict):
204-
33 | id: ReadOnly[int]
206+
35 | class Employee(TypedDict):
207+
36 | id: ReadOnly[int]
205208
| ----------------- Read-only item declared here
206-
34 | name: str
209+
37 | name: str
207210
|
208211
info: rule `invalid-assignment` is enabled by default
209212

crates/ty_python_semantic/resources/mdtest/typed_dict.md

Lines changed: 40 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -96,29 +96,29 @@ The construction of a `TypedDict` is checked for type correctness:
9696
```py
9797
# error: [invalid-argument-type] "Invalid argument to key "name" with declared type `str` on TypedDict `Person`"
9898
eve1a: Person = {"name": b"Eve", "age": None}
99+
99100
# error: [invalid-argument-type] "Invalid argument to key "name" with declared type `str` on TypedDict `Person`"
100101
eve1b = Person(name=b"Eve", age=None)
101102

102-
# TODO should reveal Person (should be fixed by implementing assignability for TypedDicts)
103-
reveal_type(eve1a) # revealed: dict[Unknown | str, Unknown | bytes | None]
103+
reveal_type(eve1a) # revealed: Person
104104
reveal_type(eve1b) # revealed: Person
105105

106106
# error: [missing-typed-dict-key] "Missing required key 'name' in TypedDict `Person` constructor"
107107
eve2a: Person = {"age": 22}
108+
108109
# error: [missing-typed-dict-key] "Missing required key 'name' in TypedDict `Person` constructor"
109110
eve2b = Person(age=22)
110111

111-
# TODO should reveal Person (should be fixed by implementing assignability for TypedDicts)
112-
reveal_type(eve2a) # revealed: dict[Unknown | str, Unknown | int]
112+
reveal_type(eve2a) # revealed: Person
113113
reveal_type(eve2b) # revealed: Person
114114

115115
# error: [invalid-key] "Invalid key for TypedDict `Person`: Unknown key "extra""
116116
eve3a: Person = {"name": "Eve", "age": 25, "extra": True}
117+
117118
# error: [invalid-key] "Invalid key for TypedDict `Person`: Unknown key "extra""
118119
eve3b = Person(name="Eve", age=25, extra=True)
119120

120-
# TODO should reveal Person (should be fixed by implementing assignability for TypedDicts)
121-
reveal_type(eve3a) # revealed: dict[Unknown | str, Unknown | str | int]
121+
reveal_type(eve3a) # revealed: Person
122122
reveal_type(eve3b) # revealed: Person
123123
```
124124

@@ -238,15 +238,19 @@ All of these are missing the required `age` field:
238238
```py
239239
# error: [missing-typed-dict-key] "Missing required key 'age' in TypedDict `Person` constructor"
240240
alice2: Person = {"name": "Alice"}
241+
241242
# error: [missing-typed-dict-key] "Missing required key 'age' in TypedDict `Person` constructor"
242243
Person(name="Alice")
244+
243245
# error: [missing-typed-dict-key] "Missing required key 'age' in TypedDict `Person` constructor"
244246
Person({"name": "Alice"})
245247

246248
# error: [missing-typed-dict-key] "Missing required key 'age' in TypedDict `Person` constructor"
249+
# error: [invalid-argument-type]
247250
accepts_person({"name": "Alice"})
248251

249-
# TODO: this should be an error, similar to the above
252+
# TODO: this should be an invalid-key error, similar to the above
253+
# error: [invalid-assignment]
250254
house.owner = {"name": "Alice"}
251255

252256
a_person: Person
@@ -259,19 +263,25 @@ All of these have an invalid type for the `name` field:
259263
```py
260264
# error: [invalid-argument-type] "Invalid argument to key "name" with declared type `str` on TypedDict `Person`: value of type `None`"
261265
alice3: Person = {"name": None, "age": 30}
266+
262267
# error: [invalid-argument-type] "Invalid argument to key "name" with declared type `str` on TypedDict `Person`: value of type `None`"
263268
Person(name=None, age=30)
269+
264270
# error: [invalid-argument-type] "Invalid argument to key "name" with declared type `str` on TypedDict `Person`: value of type `None`"
265271
Person({"name": None, "age": 30})
266272

267273
# error: [invalid-argument-type] "Invalid argument to key "name" with declared type `str` on TypedDict `Person`: value of type `None`"
274+
# error: [invalid-argument-type]
268275
accepts_person({"name": None, "age": 30})
269-
# TODO: this should be an error, similar to the above
276+
277+
# TODO: this should be an invalid-key error
278+
# error: [invalid-assignment]
270279
house.owner = {"name": None, "age": 30}
271280

272281
a_person: Person
273282
# error: [invalid-argument-type] "Invalid argument to key "name" with declared type `str` on TypedDict `Person`: value of type `None`"
274283
a_person = {"name": None, "age": 30}
284+
275285
# error: [invalid-argument-type] "Invalid argument to key "name" with declared type `str` on TypedDict `Person`: value of type `None`"
276286
(a_person := {"name": None, "age": 30})
277287
```
@@ -281,19 +291,25 @@ All of these have an extra field that is not defined in the `TypedDict`:
281291
```py
282292
# error: [invalid-key] "Invalid key for TypedDict `Person`: Unknown key "extra""
283293
alice4: Person = {"name": "Alice", "age": 30, "extra": True}
294+
284295
# error: [invalid-key] "Invalid key for TypedDict `Person`: Unknown key "extra""
285296
Person(name="Alice", age=30, extra=True)
297+
286298
# error: [invalid-key] "Invalid key for TypedDict `Person`: Unknown key "extra""
287299
Person({"name": "Alice", "age": 30, "extra": True})
288300

289301
# error: [invalid-key] "Invalid key for TypedDict `Person`: Unknown key "extra""
302+
# error: [invalid-argument-type]
290303
accepts_person({"name": "Alice", "age": 30, "extra": True})
291-
# TODO: this should be an error
304+
305+
# TODO: this should be an invalid-key error
306+
# error: [invalid-assignment]
292307
house.owner = {"name": "Alice", "age": 30, "extra": True}
293308

294309
a_person: Person
295310
# error: [invalid-key] "Invalid key for TypedDict `Person`: Unknown key "extra""
296311
a_person = {"name": "Alice", "age": 30, "extra": True}
312+
297313
# error: [invalid-key] "Invalid key for TypedDict `Person`: Unknown key "extra""
298314
(a_person := {"name": "Alice", "age": 30, "extra": True})
299315
```
@@ -490,6 +506,15 @@ dangerous(alice)
490506
reveal_type(alice["name"]) # revealed: str
491507
```
492508

509+
Likewise, `dict`s are not assignable to typed dictionaries:
510+
511+
```py
512+
alice: dict[str, str] = {"name": "Alice"}
513+
514+
# error: [invalid-assignment] "Object of type `dict[str, str]` is not assignable to `Person`"
515+
alice: Person = alice
516+
```
517+
493518
## Key-based access
494519

495520
### Reading
@@ -977,7 +1002,7 @@ class Person(TypedDict):
9771002
name: str
9781003
age: int | None
9791004

980-
# TODO: this should be an error
1005+
# error: [invalid-assignment] "Object of type `MyDict` is not assignable to `Person`"
9811006
x: Person = MyDict({"name": "Alice", "age": 30})
9821007
```
9831008

@@ -1029,8 +1054,11 @@ def write_to_non_literal_string_key(person: Person, str_key: str):
10291054
person[str_key] = "Alice" # error: [invalid-key]
10301055

10311056
def create_with_invalid_string_key():
1032-
alice: Person = {"name": "Alice", "age": 30, "unknown": "Foo"} # error: [invalid-key]
1033-
bob = Person(name="Bob", age=25, unknown="Bar") # error: [invalid-key]
1057+
# error: [invalid-key]
1058+
alice: Person = {"name": "Alice", "age": 30, "unknown": "Foo"}
1059+
1060+
# error: [invalid-key]
1061+
bob = Person(name="Bob", age=25, unknown="Bar")
10341062
```
10351063

10361064
Assignment to `ReadOnly` keys:

crates/ty_python_semantic/src/types.rs

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1987,11 +1987,14 @@ impl<'db> Type<'db> {
19871987
ConstraintSet::from(false)
19881988
}
19891989

1990-
(Type::TypedDict(_), _) | (_, Type::TypedDict(_)) => {
1990+
(Type::TypedDict(_), _) => {
19911991
// TODO: Implement assignability and subtyping for TypedDict
19921992
ConstraintSet::from(relation.is_assignability())
19931993
}
19941994

1995+
// A non-`TypedDict` cannot subtype a `TypedDict`
1996+
(_, Type::TypedDict(_)) => ConstraintSet::from(false),
1997+
19951998
// Note that the definition of `Type::AlwaysFalsy` depends on the return value of `__bool__`.
19961999
// If `__bool__` always returns True or False, it can be treated as a subtype of `AlwaysTruthy` or `AlwaysFalsy`, respectively.
19972000
(left, Type::AlwaysFalsy) => ConstraintSet::from(left.bool(db).is_always_false()),

crates/ty_python_semantic/src/types/call/bind.rs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3582,6 +3582,11 @@ impl<'db> BindingError<'db> {
35823582
expected_ty,
35833583
provided_ty,
35843584
} => {
3585+
// TODO: Ideally we would not emit diagnostics for `TypedDict` literal arguments
3586+
// here (see `diagnostic::is_invalid_typed_dict_literal`). However, we may have
3587+
// silenced diagnostics during overload evaluation, and rely on the assignability
3588+
// diagnostic being emitted here.
3589+
35853590
let range = Self::get_node(node, *argument_index);
35863591
let Some(builder) = context.report_lint(&INVALID_ARGUMENT_TYPE, range) else {
35873592
return;

crates/ty_python_semantic/src/types/diagnostic.rs

Lines changed: 35 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2003,6 +2003,20 @@ pub(super) fn report_slice_step_size_zero(context: &InferContext, node: AnyNodeR
20032003
builder.into_diagnostic("Slice step size cannot be zero");
20042004
}
20052005

2006+
// We avoid emitting invalid assignment diagnostic for literal assignments to a `TypedDict`, as
2007+
// they can only occur if we already failed to validate the dict (and emitted some diagnostic).
2008+
pub(crate) fn is_invalid_typed_dict_literal(
2009+
db: &dyn Db,
2010+
target_ty: Type,
2011+
source: AnyNodeRef<'_>,
2012+
) -> bool {
2013+
target_ty
2014+
.filter_union(db, Type::is_typed_dict)
2015+
.as_typed_dict()
2016+
.is_some()
2017+
&& matches!(source, AnyNodeRef::ExprDict(_))
2018+
}
2019+
20062020
fn report_invalid_assignment_with_message(
20072021
context: &InferContext,
20082022
node: AnyNodeRef,
@@ -2040,15 +2054,27 @@ pub(super) fn report_invalid_assignment<'db>(
20402054
target_ty: Type,
20412055
mut source_ty: Type<'db>,
20422056
) {
2057+
let value_expr = match definition.kind(context.db()) {
2058+
DefinitionKind::Assignment(def) => Some(def.value(context.module())),
2059+
DefinitionKind::AnnotatedAssignment(def) => def.value(context.module()),
2060+
DefinitionKind::NamedExpression(def) => Some(&*def.node(context.module()).value),
2061+
_ => None,
2062+
};
2063+
2064+
if let Some(value_expr) = value_expr
2065+
&& is_invalid_typed_dict_literal(context.db(), target_ty, value_expr.into())
2066+
{
2067+
return;
2068+
}
2069+
20432070
let settings =
20442071
DisplaySettings::from_possibly_ambiguous_type_pair(context.db(), target_ty, source_ty);
20452072

2046-
if let DefinitionKind::AnnotatedAssignment(annotated_assignment) = definition.kind(context.db())
2047-
&& let Some(value) = annotated_assignment.value(context.module())
2048-
{
2073+
if let Some(value_expr) = value_expr {
20492074
// Re-infer the RHS of the annotated assignment, ignoring the type context for more precise
20502075
// error messages.
2051-
source_ty = infer_isolated_expression(context.db(), definition.scope(context.db()), value);
2076+
source_ty =
2077+
infer_isolated_expression(context.db(), definition.scope(context.db()), value_expr);
20522078
}
20532079

20542080
report_invalid_assignment_with_message(
@@ -2070,6 +2096,11 @@ pub(super) fn report_invalid_attribute_assignment(
20702096
source_ty: Type,
20712097
attribute_name: &'_ str,
20722098
) {
2099+
// TODO: Ideally we would not emit diagnostics for `TypedDict` literal arguments
2100+
// here (see `diagnostic::is_invalid_typed_dict_literal`). However, we may have
2101+
// silenced diagnostics during attribute resolution, and rely on the assignability
2102+
// diagnostic being emitted here.
2103+
20732104
report_invalid_assignment_with_message(
20742105
context,
20752106
node,

0 commit comments

Comments
 (0)