Skip to content

[ty] New Type variant for TypedDict#19733

Merged
sharkdp merged 17 commits intomainfrom
david/typeddict
Aug 5, 2025
Merged

[ty] New Type variant for TypedDict#19733
sharkdp merged 17 commits intomainfrom
david/typeddict

Conversation

@sharkdp
Copy link
Contributor

@sharkdp sharkdp commented Aug 4, 2025

Summary

This PR adds a new Type::TypedDict variant. Before this PR, we treated TypedDict-based types as dynamic Todo-types, and I originally planned to make this change a no-op. And we do in fact still treat that new variant similar to a dynamic type when it comes to type properties such as assignability and subtyping. But then I somehow tricked myself into implementing some of the things correctly, so here we are. The two main behavioral changes are: (1) we now also detect generic TypedDicts, which removes a few false positives in the ecosystem, and (2) we now support attribute access (not key-based indexing!) on these types, i.e. we infer proper types for something like MyTypedDict.__required_keys__. Nothing exciting yet, but gets the infrastructure into place.

Note that with this PR, the type of (the type) MyTypedDict itself is still represented as a Type::ClassLiteral or Type::GenericAlias (in case MyTypedDict is generic). Only inhabitants of MyTypedDict (instances of dict at runtime) are represented by Type::TypedDict. We may want to revisit this decision in the future, if this turns out to be too error-prone. Right now, we need to use .is_typed_dict(db) in all the right places to distinguish between actual (generic) classes and TypedDicts. But so far, it seemed unnecessary to add additional Type variants for these as well.

Note: this PR crucially depends on salsa-rs/salsa#954 by @MichaReiser. I'm not sure if we want to merge this before it has landed in salsa. (this is now merged, updated the commit ref to the latest commit on salsa main)

part of astral-sh/ty#154

Ecosystem impact

The new diagnostics on cloud-init look like true positives to me.

Test Plan

Updated and new Markdown tests

@sharkdp sharkdp added the ty Multi-file analysis & type inference label Aug 4, 2025
@github-actions
Copy link
Contributor

github-actions bot commented Aug 4, 2025

Diagnostic diff on typing conformance tests

Changes were detected when running ty on typing conformance tests
--- old-output.txt	2025-08-05 08:54:39.534836544 +0000
+++ new-output.txt	2025-08-05 08:54:39.597836730 +0000
@@ -137,6 +137,7 @@
 callables_annotation.py:57:18: error[invalid-type-form] List literals are not allowed in this context in a type expression: Did you mean `list[int]`?
 callables_annotation.py:58:5: error[invalid-type-form] Special form `typing.Callable` expected exactly two arguments (parameter types and return type)
 callables_annotation.py:58:14: error[invalid-type-form] The first argument to `Callable` must be either a list of types, ParamSpec, Concatenate, or `...`
+callables_annotation.py:114:14: error[invalid-argument-type] `ParamSpec` is not a valid argument to `Protocol`
 callables_kwargs.py:24:5: error[type-assertion-failure] Argument does not have asserted type `int`
 callables_kwargs.py:32:9: error[type-assertion-failure] Argument does not have asserted type `str`
 callables_kwargs.py:35:5: error[type-assertion-failure] Argument does not have asserted type `str`
@@ -147,9 +148,12 @@
 callables_kwargs.py:65:5: error[missing-argument] No argument provided for required parameter `v3` of function `func2`
 callables_protocol.py:97:1: error[invalid-assignment] Object of type `def cb4_bad1(x: int) -> None` is not assignable to `Proto4`
 callables_protocol.py:121:1: error[invalid-assignment] Object of type `def cb6_bad1(*vals: bytes, *, max_len: int | None = None) -> list[bytes]` is not assignable to `NotProto6`
+callables_protocol.py:176:14: error[invalid-argument-type] `ParamSpec` is not a valid argument to `Protocol`
 callables_protocol.py:179:62: error[invalid-return-type] Function always implicitly returns `None`, which is not assignable to return type `R@__call__`
 callables_subtyping.py:26:5: error[invalid-assignment] Object of type `(int, /) -> int` is not assignable to `(int | float, /) -> int | float`
 callables_subtyping.py:29:5: error[invalid-assignment] Object of type `(int | float, /) -> int | float` is not assignable to `(int, /) -> int`
+callables_subtyping.py:204:21: error[invalid-argument-type] `ParamSpec` is not a valid argument to `Protocol`
+classes_classvar.py:35:14: error[invalid-argument-type] `ParamSpec` is not a valid argument to `Generic`
 classes_classvar.py:38:11: error[invalid-type-form] Type qualifier `typing.ClassVar` expected exactly 1 argument, got 2
 classes_classvar.py:39:14: error[invalid-type-form] Int literals are not allowed in this context in a type expression
 classes_classvar.py:40:14: error[unresolved-reference] Name `var` used when not defined
@@ -382,6 +386,7 @@
 generics_defaults.py:55:1: error[type-assertion-failure] Argument does not have asserted type `@Todo`
 generics_defaults.py:59:1: error[type-assertion-failure] Argument does not have asserted type `@Todo`
 generics_defaults.py:63:1: error[type-assertion-failure] Argument does not have asserted type `@Todo`
+generics_defaults.py:76:23: error[invalid-argument-type] `ParamSpec` is not a valid argument to `Generic`
 generics_defaults.py:79:1: error[type-assertion-failure] Argument does not have asserted type `@Todo`
 generics_defaults.py:80:1: error[type-assertion-failure] Argument does not have asserted type `@Todo`
 generics_defaults.py:91:26: error[invalid-argument-type] `@Todo` is not a valid argument to `Generic`
@@ -412,6 +417,7 @@
 generics_paramspec_semantics.py:38:28: error[invalid-return-type] Function always implicitly returns `None`, which is not assignable to return type `int`
 generics_paramspec_semantics.py:53:34: error[invalid-return-type] Function always implicitly returns `None`, which is not assignable to return type `int`
 generics_paramspec_semantics.py:57:34: error[invalid-return-type] Function always implicitly returns `None`, which is not assignable to return type `int`
+generics_paramspec_semantics.py:67:9: error[invalid-argument-type] `ParamSpec` is not a valid argument to `Generic`
 generics_paramspec_semantics.py:76:30: error[invalid-return-type] Function always implicitly returns `None`, which is not assignable to return type `str`
 generics_paramspec_semantics.py:82:5: error[type-assertion-failure] Argument does not have asserted type `@Todo`
 generics_paramspec_semantics.py:82:28: error[invalid-type-form] List literals are not allowed in this context in a type expression: Did you mean `list[int]`?
@@ -424,9 +430,12 @@
 generics_paramspec_semantics.py:133:23: error[invalid-return-type] Function always implicitly returns `None`, which is not assignable to return type `int`
 generics_paramspec_semantics.py:138:29: error[invalid-return-type] Function always implicitly returns `None`, which is not assignable to return type `int`
 generics_paramspec_semantics.py:143:25: error[invalid-return-type] Function always implicitly returns `None`, which is not assignable to return type `int`
+generics_paramspec_specialization.py:13:14: error[invalid-argument-type] `ParamSpec` is not a valid argument to `Generic`
+generics_paramspec_specialization.py:18:29: error[invalid-argument-type] `ParamSpec` is not a valid argument to `Generic`
 generics_paramspec_specialization.py:32:27: error[invalid-type-form] List literals are not allowed in this context in a type expression: Did you mean `tuple[int, bool]`?
 generics_paramspec_specialization.py:40:27: error[invalid-type-form] List literals are not allowed in this context in a type expression: Did you mean `tuple[()]`?
 generics_paramspec_specialization.py:40:31: error[invalid-type-form] List literals are not allowed in this context in a type expression: Did you mean `tuple[()]`?
+generics_paramspec_specialization.py:48:14: error[invalid-argument-type] `ParamSpec` is not a valid argument to `Generic`
 generics_paramspec_specialization.py:52:22: error[invalid-type-form] List literals are not allowed in this context in a type expression: Did you mean `tuple[int, str, bool]`?
 generics_scoping.py:14:1: error[type-assertion-failure] Argument does not have asserted type `int`
 generics_scoping.py:15:1: error[type-assertion-failure] Argument does not have asserted type `str`
@@ -532,6 +541,7 @@
 generics_typevartuple_basic.py:16:26: error[invalid-return-type] Function always implicitly returns `None`, which is not assignable to return type `tuple[@Todo, ...]`
 generics_typevartuple_basic.py:23:13: error[invalid-argument-type] `@Todo` is not a valid argument to `Generic`
 generics_typevartuple_basic.py:42:34: error[invalid-argument-type] Argument to bound method `__init__` is incorrect: Expected `tuple[@Todo, ...]`, found `Literal[1]`
+generics_typevartuple_basic.py:52:14: error[invalid-argument-type] `TypeVarTuple` is not a valid argument to `Generic`
 generics_typevartuple_basic.py:65:27: error[unknown-argument] Argument `covariant` does not match any known parameter of function `__new__`
 generics_typevartuple_basic.py:66:27: error[too-many-positional-arguments] Too many positional arguments to function `__new__`: expected 2, got 4
 generics_typevartuple_basic.py:67:27: error[unknown-argument] Argument `bound` does not match any known parameter of function `__new__`
@@ -784,6 +794,7 @@
 protocols_subtyping.py:16:6: error[call-non-callable] Cannot instantiate class `Proto1`: This call will raise `TypeError` at runtime
 protocols_subtyping.py:38:5: error[invalid-assignment] Object of type `Proto2` is not assignable to `Concrete2`
 protocols_subtyping.py:55:5: error[invalid-assignment] Object of type `Proto2` is not assignable to `Proto3`
+protocols_variance.py:84:16: error[invalid-argument-type] `ParamSpec` is not a valid argument to `Protocol`
 protocols_variance.py:85:62: error[invalid-return-type] Function always implicitly returns `None`, which is not assignable to return type `R@__call__`
 qualifiers_annotated.py:43:17: error[invalid-type-form] List literals are not allowed in this context in a type expression: Did you mean `tuple[int, str]`?
 qualifiers_annotated.py:44:17: error[invalid-type-form] Tuple literals are not allowed in this context in a type expression
@@ -885,4 +896,4 @@
 tuples_type_form.py:36:1: error[invalid-assignment] Object of type `tuple[Literal[1], Literal[2], Literal[3], Literal[""]]` is not assignable to `tuple[int, ...]`
 typeddicts_operations.py:60:1: error[type-assertion-failure] Argument does not have asserted type `str | None`
 typeddicts_type_consistency.py:101:1: error[invalid-assignment] Object of type `Unknown | None` is not assignable to `str`
-Found 886 diagnostics
+Found 897 diagnostics

@github-actions
Copy link
Contributor

github-actions bot commented Aug 4, 2025

mypy_primer results

Changes were detected when running on open source projects
pydantic (https://github.com/pydantic/pydantic)
- pydantic/json_schema.py:1734:9: error[invalid-assignment] Object of type `Any | list[Any]` is not assignable to `ConfigDict`
- Found 767 diagnostics
+ Found 766 diagnostics

yarl (https://github.com/aio-libs/yarl)
- tests/test_url.py:2395:25: warning[unused-ignore-comment] Unused blanket `type: ignore` directive
- Found 49 diagnostics
+ Found 48 diagnostics

cloud-init (https://github.com/canonical/cloud-init)
+ cloudinit/netinfo.py:596:25: error[invalid-argument-type] Method `__getitem__` of type `Overload[(key: SupportsIndex | slice[Any, Any, Any], /) -> LiteralString, (key: SupportsIndex | slice[Any, Any, Any], /) -> str]` cannot be called with key of type `Literal["ip"]` on object of type `str`
+ cloudinit/netinfo.py:597:25: error[invalid-argument-type] Method `__getitem__` of type `Overload[(key: SupportsIndex | slice[Any, Any, Any], /) -> LiteralString, (key: SupportsIndex | slice[Any, Any, Any], /) -> str]` cannot be called with key of type `Literal["mask"]` on object of type `str`
+ cloudinit/netinfo.py:598:25: warning[possibly-unbound-attribute] Attribute `get` on type `str | @Todo(Support for `TypedDict`)` is possibly unbound
+ cloudinit/netinfo.py:609:25: error[invalid-argument-type] Method `__getitem__` of type `Overload[(key: SupportsIndex | slice[Any, Any, Any], /) -> LiteralString, (key: SupportsIndex | slice[Any, Any, Any], /) -> str]` cannot be called with key of type `Literal["ip"]` on object of type `str`
+ cloudinit/netinfo.py:611:25: warning[possibly-unbound-attribute] Attribute `get` on type `str | @Todo(Support for `TypedDict`)` is possibly unbound
+ cloudinit/sources/DataSourceVMware.py:1017:20: error[invalid-argument-type] Method `__getitem__` of type `Overload[(key: SupportsIndex | slice[Any, Any, Any], /) -> LiteralString, (key: SupportsIndex | slice[Any, Any, Any], /) -> str]` cannot be called with key of type `Literal["ip"]` on object of type `str`
+ cloudinit/sources/DataSourceVMware.py:1027:20: error[invalid-argument-type] Method `__getitem__` of type `Overload[(key: SupportsIndex | slice[Any, Any, Any], /) -> LiteralString, (key: SupportsIndex | slice[Any, Any, Any], /) -> str]` cannot be called with key of type `Literal["ip"]` on object of type `str`
+ cloudinit/sources/DataSourceVMware.py:1155:62: error[invalid-argument-type] Argument to function `convert_to_netifaces_ipv4_format` is incorrect: Expected `dict[Unknown, Unknown]`, found `str`
+ cloudinit/sources/DataSourceVMware.py:1157:62: error[invalid-argument-type] Argument to function `convert_to_netifaces_ipv6_format` is incorrect: Expected `dict[Unknown, Unknown]`, found `str`
- Found 598 diagnostics
+ Found 607 diagnostics

urllib3 (https://github.com/urllib3/urllib3)
+ src/urllib3/connection.py:992:70: warning[unused-ignore-comment] Unused blanket `type: ignore` directive
- Found 395 diagnostics
+ Found 396 diagnostics

altair (https://github.com/vega/altair)
+ tests/vegalite/v6/test_api.py:410:42: warning[unused-ignore-comment] Unused blanket `type: ignore` directive
+ tests/vegalite/v6/test_api.py:430:41: warning[unused-ignore-comment] Unused blanket `type: ignore` directive
+ tests/vegalite/v6/test_api.py:465:57: warning[unused-ignore-comment] Unused blanket `type: ignore` directive
- tests/vegalite/v6/test_theme.py:1073:40: warning[unused-ignore-comment] Unused blanket `type: ignore` directive
- tests/vegalite/v6/test_theme.py:1074:39: warning[unused-ignore-comment] Unused blanket `type: ignore` directive
- Found 1303 diagnostics
+ Found 1304 diagnostics

openlibrary (https://github.com/internetarchive/openlibrary)
+ openlibrary/core/lists/model.py:421:24: warning[possibly-unbound-attribute] Attribute `key` on type `(Thing & ~str & ~dict[Unknown, Unknown]) | (AnnotatedSeed & ~str & ~dict[Unknown, Unknown])` is possibly unbound
- Found 705 diagnostics
+ Found 706 diagnostics

zulip (https://github.com/zulip/zulip)
- corporate/views/support.py:582:35: error[invalid-argument-type] Argument to bound method `__init__` is incorrect: Expected `Realm | None`, found `Self@Model`
- corporate/views/support.py:894:52: error[invalid-argument-type] Argument to bound method `__init__` is incorrect: Expected `RemoteRealm`, found `Self@Model`
- corporate/views/support.py:894:65: warning[possibly-unresolved-reference] Name `remote_realm` used when possibly not defined
- corporate/views/support.py:902:52: error[invalid-argument-type] Argument to bound method `__init__` is incorrect: Expected `RemoteZulipServer`, found `Self@Model`
- corporate/views/support.py:902:66: warning[possibly-unresolved-reference] Name `remote_server` used when possibly not defined
- zerver/lib/message_report.py:35:60: error[invalid-argument-type] Argument to function `silent_mention_syntax_for_user` is incorrect: Expected `UserProfile | UserDisplayRecipient`, found `Unknown | _ST@ForeignKey`
- zerver/lib/reminders.py:35:58: error[invalid-argument-type] Argument to function `silent_mention_syntax_for_user` is incorrect: Expected `UserProfile | UserDisplayRecipient`, found `Unknown | _ST@ForeignKey`
- Found 7424 diagnostics
+ Found 7417 diagnostics
No memory usage changes detected ✅

@codspeed-hq
Copy link

codspeed-hq bot commented Aug 4, 2025

CodSpeed Instrumentation Performance Report

Merging #19733 will not alter performance

Comparing david/typeddict (bfa22e9) with main (351121c)

Summary

✅ 42 untouched benchmarks

@github-actions

This comment was marked as off-topic.

@github-actions
Copy link
Contributor

github-actions bot commented Aug 4, 2025

ecosystem-analyzer results

Lint rule Added Removed Changed
invalid-argument-type 7 5 0
unused-ignore-comment 4 3 0
possibly-unbound-attribute 3 0 0
possibly-unresolved-reference 0 2 0
invalid-assignment 0 1 0
Total 14 11 0

Full report with detailed diff

@sharkdp sharkdp changed the title [ty] New Type variant for TypedDict [ty] New Type variant for TypedDict Aug 4, 2025
@sharkdp sharkdp marked this pull request as ready for review August 4, 2025 19:04
Copy link
Member

@AlexWaygood AlexWaygood left a comment

Choose a reason for hiding this comment

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

Thank you! TypedDict types are odd in a number of ways; there's a few things to think through here --

Comment on lines +184 to +186
reveal_type(Person.__total__) # revealed: bool
reveal_type(Person.__required_keys__) # revealed: frozenset[str]
reveal_type(Person.__optional_keys__) # revealed: frozenset[str]
Copy link
Member

Choose a reason for hiding this comment

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

is it worth also adding some tests that demonstrate that these attributes cannot be accessed from inhabitants of the TypedDict type (unlike most ClassVars)?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Yes, thanks. I added a test, but it fails (added a TODO). No other type checker gets this right, so I'm not going to invest time right now.

Type::AlwaysTruthy | Type::AlwaysFalsy => KnownClass::Type.to_instance(db),
Type::BoundSuper(_) => KnownClass::Super.to_class_literal(db),
Type::ProtocolInstance(protocol) => protocol.to_meta_type(db),
Type::TypedDict(typed_dict) => SubclassOfType::from(db, typed_dict.defining_class(db)),
Copy link
Member

Choose a reason for hiding this comment

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

I'm not sure this is correct, because at runtime TypedDict inhabitants are always instances of exactly dict. We use Type::to_meta_type() to infer the type of things like type(x) and x.__class__ -- if x is an inhabitant of a TypedDict type, both of those will be <class 'dict'> at runtime. Which would imply that this should be

Suggested change
Type::TypedDict(typed_dict) => SubclassOfType::from(db, typed_dict.defining_class(db)),
Type::TypedDict(typed_dict) => KnownClass::Dict.to_class_literal(db),

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I don't think so. This way, we would lose important information. Sure, inhabitants are instances of dict at runtime. But a Type::TypedDict also represents a (special sub)set of dict instances. And its meta type should be the specific class that describes that subset.

Consider what happens if we access person["name"] on person: Person. Under the hood, we call type(person).__getitem__(person, "name"). If we model type(person) as being of type <class 'dict'>, we would get a generic answer like object. But we want this to be the type of the name item on Person.

Other type checkers also infer type[Person] for type(Person) (except for mypy, which infers Person as a function).

Copy link
Member

Choose a reason for hiding this comment

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

Hmm, okay, interesting. I think what this maybe implies in that case is that we'll need to treat type[] types quite differently for TypedDicts than for other classes: you can only access the synthesised TypedDict methods (and any methods from Mapping) on type(x) (where x is a TypedDict inhabitant) — you can't access __required_keys__ and the other ClassVars on _TypedDictFallback from type(x)

Copy link
Contributor Author

@sharkdp sharkdp Aug 5, 2025

Choose a reason for hiding this comment

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

That's a good observation, yes. I attempted to fix this and found a pre-existing bug related to annotated assignments without a right hand side (but with type qualifiers) in stub files. As this relates to the __total__: ClassVar[bool] annotations in TypedDictFallback, I need to fix this first. I'd like to do that in a separate PR as it might have a wider-spread impact.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

See bugfix here and follow-up here.

| Type::Dynamic(..)
| Type::Never => {
| Type::Never
| Type::TypedDict(_) => {
Copy link
Member

Choose a reason for hiding this comment

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

they're always instances of exactly dict at runtime, but it may not be worth the complexity trying to model that here

Comment on lines +166 to +172
Type::TypedDict(_) => {
if let Type::ClassLiteral(class_literal) = ty.to_meta_type(db) {
self.extend_with_class_members(db, ty, class_literal);
}

self.extend_with_type(db, KnownClass::TypedDictFallback.to_instance(db));
}
Copy link
Member

Choose a reason for hiding this comment

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

(You'll need to change this too if you think my comments above about the meta-type of TypedDicts is reasonable)

| Type::PropertyInstance(_)
| Type::TypeIs(_) => None,
| Type::TypeIs(_)
| Type::TypedDict(_) => None,
Copy link
Contributor

Choose a reason for hiding this comment

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

Hmm, shouldn't this fallback to attributes of dict? (But I must be missing something, since it seems like similar should be true for many of the other types above in this arm.)

Copy link
Contributor Author

@sharkdp sharkdp Aug 5, 2025

Choose a reason for hiding this comment

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

find_name_in_mro only works on class-like types. It is conceptually similar to the following function:

def find_name_in_mro(cls, name, default):
    "Emulate _PyType_Lookup() in Objects/typeobject.c"
    for base in cls.__mro__:
        if name in vars(base):
            return vars(base)[name]
    return default

Accessing members on instance-like types will always go through class_member, which calls to_meta_type first (ty.class_member(…) = ty.to_meta_type(…).find_name_in_mro(…)), similar to how an attribute access of obj.attr is always type(obj).attr under the hood (except for instance attributes, which are handled elsewhere).

Copy link
Member

@AlexWaygood AlexWaygood left a comment

Choose a reason for hiding this comment

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

LGTM if we're happy with the salsa bump!

@sharkdp
Copy link
Contributor Author

sharkdp commented Aug 5, 2025

LGTM if we're happy with the salsa bump!

@MichaReiser's PR has been merged and I updated the commit to the latest from salsa main.

@sharkdp sharkdp merged commit 14fbc2b into main Aug 5, 2025
38 checks passed
@sharkdp sharkdp deleted the david/typeddict branch August 5, 2025 09:19
Comment on lines 233 to 234

For unions, `ide_support::all_members` only returns members that are available on all elements of
Copy link
Member

Choose a reason for hiding this comment

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

It looks like you maybe deleted the ### Unions header above accidentally here

Suggested change
For unions, `ide_support::all_members` only returns members that are available on all elements of
### Unions
For unions, `ide_support::all_members` only returns members that are available on all elements of

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Ah, yeah. I noticed that to independently and fixed it in #19758

sharkdp added a commit that referenced this pull request Aug 5, 2025
## Summary

This PR fixes a few inaccuracies in attribute access on `TypedDict`s. It
also changes the return type of `type(person)` to `type[dict[str,
object]]` if `person: Person` is an inhabitant of a `TypedDict`
`Person`. We still use `type[Person]` as the *meta type* of Person,
however (see reasoning
[here](#19733 (comment))).

## Test Plan

Updated Markdown tests.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

ecosystem-analyzer ty Multi-file analysis & type inference

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants