-
Notifications
You must be signed in to change notification settings - Fork 1.8k
Narrow closed TypedDicts on a key membership check #11620
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Rich Chiodo (rchiodo)
merged 2 commits into
microsoft:main
from
nileshpatil6:fix-closed-typeddict-narrowing
Aug 11, 2026
+111
−5
Merged
Changes from all commits
Commits
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
99 changes: 99 additions & 0 deletions
99
packages/pyright-internal/src/tests/samples/typedDictClosed11.py
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,99 @@ | ||
| # This sample tests narrowing of a union of closed TypedDicts based on | ||
| # an "in" check for a key. A closed TypedDict cannot contain a key that | ||
| # is not one of its known items, so such a check can discriminate. | ||
|
|
||
|
|
||
| from typing import Never, NotRequired, TypedDict | ||
|
|
||
|
|
||
| class Foo(TypedDict, closed=True): | ||
| foo: int | ||
|
|
||
|
|
||
| class Bar(TypedDict, closed=True): | ||
| bar: int | ||
|
|
||
|
|
||
| def func1(u: Foo | Bar) -> int: | ||
| if "foo" in u: | ||
| reveal_type(u, expected_text="Foo") | ||
| return u["foo"] | ||
| else: | ||
| reveal_type(u, expected_text="Bar") | ||
| return u["bar"] | ||
|
|
||
|
|
||
| def func2(u: Foo | Bar) -> int: | ||
| if "bar" not in u: | ||
| reveal_type(u, expected_text="Foo") | ||
| return u["foo"] | ||
| else: | ||
| reveal_type(u, expected_text="Bar") | ||
| return u["bar"] | ||
|
|
||
|
|
||
| class Baz(TypedDict, extra_items=int): | ||
| baz: int | ||
|
|
||
|
|
||
| def func3(u: Foo | Baz) -> None: | ||
| # "Baz" allows extra items, so it cannot be eliminated here. | ||
| if "foo" in u: | ||
| reveal_type(u, expected_text="Foo | Baz") | ||
| else: | ||
| reveal_type(u, expected_text="Baz") | ||
|
|
||
|
|
||
| class Open(TypedDict): | ||
| other: int | ||
|
|
||
|
|
||
| def func4(u: Foo | Open) -> None: | ||
| # An open TypedDict without "extra_items" is narrowed on a key check | ||
| # even though it is not sound to do so; this is idiomatic and is | ||
| # relied upon in practice. | ||
| if "foo" in u: | ||
| reveal_type(u, expected_text="Foo") | ||
| else: | ||
| reveal_type(u, expected_text="Open") | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Consider adding a closed-TypedDict union with an overlapping key to exercise the existing required-key retain/clone-and-mark-provided path alongside the new elimination path. |
||
|
|
||
|
|
||
| class NeverItem(TypedDict): | ||
| always: int | ||
| never: Never | ||
|
|
||
|
|
||
| def func5(td: NeverItem) -> None: | ||
| # A declared item typed as Never can never be present either, so the | ||
| # same elimination applies to it and not only to the "extra items" | ||
| # entry synthesized for a closed TypedDict. | ||
| if "never" in td: | ||
| reveal_type(td, expected_text="Never") | ||
| else: | ||
| reveal_type(td, expected_text="NeverItem") | ||
|
|
||
|
|
||
| class Left(TypedDict, closed=True): | ||
| common: int | ||
| left: int | ||
|
|
||
|
|
||
| class Right(TypedDict, closed=True): | ||
| common: int | ||
| right: NotRequired[int] | ||
|
|
||
|
|
||
| def func6(u: Left | Right) -> None: | ||
| # "common" is a required known item of both, so neither is eliminated. | ||
| if "common" in u: | ||
| reveal_type(u, expected_text="Left | Right") | ||
| else: | ||
| reveal_type(u, expected_text="Never") | ||
|
|
||
|
|
||
| def func7(td: Right) -> None: | ||
| # "right" is a known item that is not required, so the subtype is kept | ||
| # and the key is marked as provided rather than eliminated. | ||
| if "right" in td: | ||
| reveal_type(td, expected_text="Right") | ||
| reveal_type(td["right"], expected_text="int") | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The new guard also changes positive membership checks for declared keys typed
Never, not only synthesized closed-TypedDict extra-items. Please add a small regression case to pin that intended behavior.