Skip to content

Narrow closed TypedDicts on a key membership check - #11620

Merged
Rich Chiodo (rchiodo) merged 2 commits into
microsoft:mainfrom
nileshpatil6:fix-closed-typeddict-narrowing
Aug 11, 2026
Merged

Narrow closed TypedDicts on a key membership check#11620
Rich Chiodo (rchiodo) merged 2 commits into
microsoft:mainfrom
nileshpatil6:fix-closed-typeddict-narrowing

Conversation

@nileshpatil6

@nileshpatil6 Nilesh Patil (nileshpatil6) commented Aug 11, 2026

Copy link
Copy Markdown
Contributor

Fixes #11518.

A union of closed TypedDicts can't be discriminated with an in check today:

class Foo(TypedDict, closed=True):
    foo: int

class Bar(TypedDict, closed=True):
    bar: int

def get_field(u: Foo | Bar) -> int:
    if "foo" in u:
        return u["foo"]  # error: "foo" is not a defined key in "Bar"
    else:
        return u["bar"]

Bar is closed, so it can't hold a "foo" key and should be eliminated in the positive branch.

The cause is in narrowTypeForTypedDictKey. getTypedDictMembersForClass represents closed=True by synthesizing an "extra items" entry whose value type is Never, and the positive branch looks up knownItems.get(key) ?? extraItems. For a closed TypedDict and an unknown key that lookup returns the synthesized Never entry, which is not undefined, so the code treats it as a real entry and keeps the subtype.

The check for this was already written in that function, commented out. I enabled just the isNever half. The other commented block (returning subtype when there's no entry at all) stays as it is, because that's the one that would disable the deliberately-unsound narrowing of open TypedDicts discussed in #10805, and the comment above it explains why it's being kept for now.

Added typedDictClosed11.py with seven cases: the union from the issue, the same thing through not in, a Foo | Baz union where Baz declares extra_items=int and so must not be eliminated, a Foo | Open union where Open is an ordinary TypedDict, pinning that the existing idiomatic narrowing still happens, a TypedDict with an item declared as Never, a union of two closed TypedDicts sharing a required key, and a closed TypedDict with a NotRequired key that takes the mark-as-provided path. Put it in a new sample rather than extending typedDictClosed1.py so it doesn't collide with #11610, which is still open against that file.

The new sample reports five errors before the change and none after: the four from the issue (two failed reveal_type narrowings and the two key-access errors that follow) plus the declared-Never case. All eleven TypedDictClosed tests pass.

Ran the narrowing tests across all suites (54 passing) and the full typeEvaluator5 suite (68 passing) locally on Windows.

@StellaHuang95

Copy link
Copy Markdown
Collaborator

🔒 Automated review in progress — Stella Huang (@StellaHuang95) is auto-reviewing this PR.

// A closed TypedDict has an "extra items" of Never, so this is what allows
// a key check to discriminate between closed TypedDicts.
return undefined;
}

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Info · Optional note

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.

if "foo" in u:
reveal_type(u, expected_text="Foo")
else:
reveal_type(u, expected_text="Open")

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Info · Optional note

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.

@StellaHuang95

Copy link
Copy Markdown
Collaborator

The negative branch implicitly relies on closed TypedDicts' synthesized Never extra-items entry being non-required and non-provided. A brief comment there could help preserve this subtle symmetry in future edits.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Approved via Review Center.

@StellaHuang95 Stella Huang (StellaHuang95) added the review-auto:approved Automated review: no blocking findings (approval posted). label Aug 11, 2026
@nileshpatil6

Copy link
Copy Markdown
Contributor Author

Thanks, all three were worth doing. Added the two test cases in a7b094b.

On the declared-Never item: you're right that this isn't limited to the synthesized closed-TypedDict entry, and it was worth pinning rather than leaving implied. func5 covers a TypedDict with an item declared as Never, and a positive check on that key now eliminates the subtype. I checked it fails against the previous commit ("expected Never but received NeverItem"), so it's holding the new behavior rather than passing either way. I think eliminating there is right for the same reason as the closed case, since a key whose value type is uninhabited can't be present, but it's now explicit in the tests either way.

On the overlapping key: added Left | Right, both closed and both declaring common. A check on common keeps both subtypes, and the negative branch eliminates both since it's required in each. func7 covers the other half with right: NotRequired[int], which takes the clone-and-mark-provided path rather than being eliminated. Both of those pass before and after the change, which is the point of having them.

On the comment in the negative branch: that branch is what #11610 rewrites, and it adds the note you're describing as part of the change, distinguishing the known item from the "extra items" fallback so the asymmetry is stated outright. I've left the negative branch untouched here so the two PRs don't collide on the same lines. If #11610 ends up not landing, say the word and I'll bring the comment over to this one.

@StellaHuang95

Copy link
Copy Markdown
Collaborator

Please update the PR description: the committed sample contains seven cases, not four, so its stated pre-change error count is stale.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Approved via Review Center.

@github-actions

Copy link
Copy Markdown
Contributor

Diff from mypy_primer, showing the effect of this PR on open source code:

sympy (https://github.com/sympy/sympy)
+   .../projects/sympy/sympy/simplify/powsimp.py:686:13 - error: Operator "*=" not supported for types "Unknown | Literal[1]" and "Basic | Unknown"
+     Operator "*" not supported for types "Literal[1]" and "Basic" (reportOperatorIssue)
+   .../projects/sympy/sympy/simplify/powsimp.py:688:24 - error: Argument of type "Basic | Unknown" cannot be assigned to parameter "b" of type "Expr | complex" in function "__new__"
+     Type "Basic | Unknown" is not assignable to type "Expr | complex"
+       Type "Basic" is not assignable to type "Expr | complex"
+         "Basic" is not assignable to "Expr"
+         "Basic" is not assignable to "complex" (reportArgumentType)
+   .../projects/sympy/sympy/simplify/powsimp.py:704:29 - error: Cannot access attribute "as_numer_denom" for class "GaussianRational"
+     Attribute "as_numer_denom" is unknown (reportAttributeAccessIssue)
+   .../projects/sympy/sympy/simplify/powsimp.py:704:29 - error: Cannot access attribute "as_numer_denom" for class "MPQ"
+     Attribute "as_numer_denom" is unknown (reportAttributeAccessIssue)
+   .../projects/sympy/sympy/simplify/powsimp.py:704:29 - error: Cannot access attribute "as_numer_denom" for class "GaussianInteger"
+     Attribute "as_numer_denom" is unknown (reportAttributeAccessIssue)
+   .../projects/sympy/sympy/simplify/powsimp.py:704:29 - error: Cannot access attribute "as_numer_denom" for class "MPZ"
+     Attribute "as_numer_denom" is unknown (reportAttributeAccessIssue)
+   .../projects/sympy/sympy/simplify/powsimp.py:713:24 - error: Cannot access attribute "as_coeff_Mul" for class "Basic"
+     Attribute "as_coeff_Mul" is unknown (reportAttributeAccessIssue)
+   .../projects/sympy/sympy/simplify/powsimp.py:714:46 - error: Operator "/" not supported for types "Basic | Unknown" and "Basic"
+     Operator "/" not supported for types "Basic" and "Basic" (reportOperatorIssue)
-   .../projects/sympy/sympy/simplify/radsimp.py:939:20 - error: Operator "/" not supported for types "Literal[1]" and "Expr | Unknown | None"
+   .../projects/sympy/sympy/simplify/radsimp.py:939:20 - error: Operator "/" not supported for types "Literal[1]" and "Unknown | Expr | None"
-   .../projects/sympy/sympy/simplify/radsimp.py:950:36 - error: Argument of type "Expr | Unknown | Basic | bool | None" cannot be assigned to parameter "expr" of type "Expr" in function "make_args"
+   .../projects/sympy/sympy/simplify/radsimp.py:950:36 - error: Argument of type "Unknown | Expr | Basic | bool | None" cannot be assigned to parameter "expr" of type "Expr" in function "make_args"
-     Type "Expr | Unknown | Basic | bool | None" is not assignable to type "Expr"
+     Type "Unknown | Expr | Basic | bool | None" is not assignable to type "Expr"
-   .../projects/sympy/sympy/simplify/radsimp.py:999:36 - error: Operator "/" not supported for types "Literal[1]" and "Expr | Unknown | Basic | bool | Add | None"
+   .../projects/sympy/sympy/simplify/radsimp.py:999:36 - error: Operator "/" not supported for types "Literal[1]" and "Unknown | Expr | Basic | bool | Add | None"
+   .../projects/sympy/sympy/simplify/radsimp.py:1196:14 - error: Operator "*" not supported for types "Unknown | GaussianRational | MPQ | GaussianInteger | MPZ | Expr | Rational | NaN | ComplexInfinity | One | NegativeOne | Zero | Integer | Infinity | NegativeInfinity | Float | Number | Poly | Any" and "Unknown | GaussianRational | MPQ | GaussianInteger | MPZ | Expr | Rational | NaN | ComplexInfinity | One | NegativeOne | Zero | Integer | Infinity | NegativeInfinity | Float | Number | Poly | Any"
+     Operator "*" not supported for types "GaussianRational" and "MPQ"
+     Operator "*" not supported for types "GaussianRational" and "GaussianInteger"
+     Operator "*" not supported for types "GaussianRational" and "MPZ"
+     Operator "*" not supported for types "GaussianRational" and "Expr"
+     Operator "*" not supported for types "GaussianRational" and "Rational"
+     Operator "*" not supported for types "GaussianRational" and "NaN"
+     Operator "*" not supported for types "GaussianRational" and "ComplexInfinity"
+     Operator "*" not supported for types "GaussianRational" and "Infinity"
+     ... (reportOperatorIssue)
+   .../projects/sympy/sympy/simplify/ratsimp.py:31:17 - error: Argument of type "Expr | Unknown | Poly" cannot be assigned to parameter "args" of type "Expr | complex" in function "__new__"
+     Type "Expr | Unknown | Poly" is not assignable to type "Expr | complex"
+       Type "Poly" is not assignable to type "Expr | complex"
+         "Poly" is not assignable to "Expr"
+         "Poly" is not assignable to "complex" (reportArgumentType)
-   .../projects/sympy/sympy/simplify/simplify.py:163:20 - error: Argument of type "dict[str, Unknown] | dict[Unknown, list[Unknown]] | Expr | Abs | Unknown | None" cannot be assigned to parameter "b" of type "Expr | complex" in function "__new__"
+   .../projects/sympy/sympy/simplify/simplify.py:163:20 - error: Argument of type "dict[str, Unknown] | dict[Unknown, list[Unknown]] | Unknown | None" cannot be assigned to parameter "b" of type "Expr | complex" in function "__new__"
-     Type "dict[str, Unknown] | dict[Unknown, list[Unknown]] | Expr | Abs | Unknown | None" is not assignable to type "Expr | complex"
+     Type "dict[str, Unknown] | dict[Unknown, list[Unknown]] | Unknown | None" is not assignable to type "Expr | complex"
-   .../projects/sympy/sympy/simplify/sqrtdenest.py:157:19 - error: Operator "**" not supported for types "Basic" and "Literal[2]" (reportOperatorIssue)
-   .../projects/sympy/sympy/simplify/sqrtdenest.py:200:48 - error: Argument of type "Basic" cannot be assigned to parameter "args" of type "Expr | complex" in function "__new__"
-     Type "Basic" is not assignable to type "Expr | complex"
-       "Basic" is not assignable to "Expr"
-       "Basic" is not assignable to "complex" (reportArgumentType)
-   .../projects/sympy/sympy/simplify/sqrtdenest.py:207:26 - error: Operator "**" not supported for types "Unknown | Mul | Basic" and "Literal[2]"
+   .../projects/sympy/sympy/simplify/sqrtdenest.py:309:10 - error: Operator "+" not supported for types "Expr" and "Unknown | Expr | Mul | None"
+     Operator "+" not supported for types "Expr" and "None" (reportOperatorIssue)
+   .../projects/sympy/sympy/simplify/sqrtdenest.py:340:19 - error: Operator "-" not supported for types "Expr | Unknown | GaussianRational | One | NegativeOne | Zero | Integer | Any | MPQ | GaussianInteger | MPZ | Poly | NotImplementedType" and "Expr | Unknown | Any | GaussianRational | One | NegativeOne | Zero | Integer | NaN | ComplexInfinity | Rational | Infinity | NegativeInfinity | Float | NotImplementedType | MPQ | GaussianInteger | MPZ | Poly"
+     Operator "-" not supported for types "Expr" and "GaussianRational"
+     Operator "-" not supported for types "Expr" and "MPQ"
+     Operator "-" not supported for types "Expr" and "GaussianInteger"
+     Operator "-" not supported for types "Expr" and "MPZ"
+     Operator "-" not supported for types "GaussianRational" and "Expr"
+     Operator "-" not supported for types "GaussianRational" and "NaN"
+     Operator "-" not supported for types "GaussianRational" and "ComplexInfinity"
+     Operator "-" not supported for types "GaussianRational" and "Rational"
+     ... (reportOperatorIssue)
+   .../projects/sympy/sympy/simplify/sqrtdenest.py:340:26 - error: Operator "*" not supported for types "Expr | Unknown | GaussianRational | One | NegativeOne | Zero | Integer | Any | MPQ | GaussianInteger | MPZ | Poly | NotImplementedType" and "Expr | Unknown | GaussianRational | One | NegativeOne | Zero | Integer | NaN | ComplexInfinity | Rational | Any | MPQ | GaussianInteger | MPZ | Infinity | NegativeInfinity | Float | NotImplementedType | Poly | Number"
+     Operator "*" not supported for types "Expr" and "GaussianRational"
+     Operator "*" not supported for types "Expr" and "MPQ"
+     Operator "*" not supported for types "Expr" and "GaussianInteger"
+     Operator "*" not supported for types "Expr" and "MPZ"
+     Operator "*" not supported for types "GaussianRational" and "Expr"
+     Operator "*" not supported for types "GaussianRational" and "NaN"
+     Operator "*" not supported for types "GaussianRational" and "ComplexInfinity"
+     Operator "*" not supported for types "GaussianRational" and "Rational"
+     ... (reportOperatorIssue)
+   .../projects/sympy/sympy/simplify/sqrtdenest.py:341:11 - error: Cannot access attribute "is_Rational" for class "GaussianRational"
+     Attribute "is_Rational" is unknown (reportAttributeAccessIssue)
+   .../projects/sympy/sympy/simplify/sqrtdenest.py:341:11 - error: Cannot access attribute "is_Rational" for class "MPQ"
+     Attribute "is_Rational" is unknown (reportAttributeAccessIssue)
+   .../projects/sympy/sympy/simplify/sqrtdenest.py:341:11 - error: Cannot access attribute "is_Rational" for class "GaussianInteger"
+     Attribute "is_Rational" is unknown (reportAttributeAccessIssue)
+   .../projects/sympy/sympy/simplify/sqrtdenest.py:341:11 - error: Cannot access attribute "is_Rational" for class "MPZ"
+     Attribute "is_Rational" is unknown (reportAttributeAccessIssue)
+   .../projects/sympy/sympy/simplify/sqrtdenest.py:342:15 - error: Cannot access attribute "is_positive" for class "GaussianRational"
+     Attribute "is_positive" is unknown (reportAttributeAccessIssue)
+   .../projects/sympy/sympy/simplify/sqrtdenest.py:342:15 - error: Cannot access attribute "is_positive" for class "MPQ"
+     Attribute "is_positive" is unknown (reportAttributeAccessIssue)
+   .../projects/sympy/sympy/simplify/sqrtdenest.py:342:15 - error: Cannot access attribute "is_positive" for class "GaussianInteger"
+     Attribute "is_positive" is unknown (reportAttributeAccessIssue)
+   .../projects/sympy/sympy/simplify/sqrtdenest.py:342:15 - error: Cannot access attribute "is_positive" for class "MPZ"

... (truncated 407 lines) ...

@rchiodo
Rich Chiodo (rchiodo) merged commit 870afb0 into microsoft:main Aug 11, 2026
16 checks passed
@nileshpatil6

Copy link
Copy Markdown
Contributor Author

Updated the description, thanks for catching it. It now says seven cases and five pre-change errors, the extra one being the declared-Never case from func5. Missed that when I added the two cases from your review.

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

Labels

review-auto:approved Automated review: no blocking findings (approval posted).

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Closed TypedDicts losing type discrimination

3 participants