From c9168af8627ea85a3240a0b70a10389650289fd1 Mon Sep 17 00:00:00 2001 From: nileshpatil6 Date: Sun, 9 Aug 2026 15:40:24 +0530 Subject: [PATCH 1/2] Fix TypedDict 'in' narrowing wrongly eliminating subtype for extra_items keys In narrowTypeForTypedDictKey the negative branch treated a key that only matched the extra_items pseudo-entry the same as a known item, so for a closed TypedDict with extra_items a check like 'if "key" in td' made the else branch unreachable. An extra item may or may not be present, so only a known required or provided item can eliminate the subtype. --- .../pyright-internal/src/analyzer/typeGuards.ts | 10 ++++++++-- .../src/tests/samples/typedDictClosed1.py | 13 +++++++++++++ .../src/tests/typeEvaluator5.test.ts | 2 +- 3 files changed, 22 insertions(+), 3 deletions(-) diff --git a/packages/pyright-internal/src/analyzer/typeGuards.ts b/packages/pyright-internal/src/analyzer/typeGuards.ts index 7ee3a18d5b37..890c012569bf 100644 --- a/packages/pyright-internal/src/analyzer/typeGuards.ts +++ b/packages/pyright-internal/src/analyzer/typeGuards.ts @@ -2257,7 +2257,8 @@ function narrowTypeForTypedDictKey( if (isClassInstance(subtype) && ClassType.isTypedDictClass(subtype)) { const entries = getTypedDictMembersForClass(evaluator, subtype, /* allowNarrowed */ true); - const tdEntry = entries.knownItems.get(literalKey.priv.literalValue as string) ?? entries.extraItems; + const knownItemEntry = entries.knownItems.get(literalKey.priv.literalValue as string); + const tdEntry = knownItemEntry ?? entries.extraItems; if (isPositiveTest) { // The code that is commented out below implements the behavior that is technically @@ -2309,7 +2310,12 @@ function narrowTypeForTypedDictKey( ) ); } else { - return tdEntry !== undefined && (tdEntry.isRequired || tdEntry.isProvided) ? undefined : subtype; + // Only a known item can be guaranteed to be present. If the key + // matched the "extra items" entry, the key may or may not be + // present, so the subtype cannot be eliminated in this case. + return knownItemEntry !== undefined && (knownItemEntry.isRequired || knownItemEntry.isProvided) + ? undefined + : subtype; } } diff --git a/packages/pyright-internal/src/tests/samples/typedDictClosed1.py b/packages/pyright-internal/src/tests/samples/typedDictClosed1.py index 81ac9f2ba922..bd978cf1549b 100644 --- a/packages/pyright-internal/src/tests/samples/typedDictClosed1.py +++ b/packages/pyright-internal/src/tests/samples/typedDictClosed1.py @@ -35,6 +35,19 @@ def func1(movie: Movie) -> None: movie["other2"] = 1 +def func2(movie: Movie) -> None: + # An extra item is not guaranteed to be present, so the negative + # branch of the "in" check must remain reachable. + if "novel_adaptation" in movie: + return + + reveal_type(movie, expected_text="Movie") + + # This should generate a type incompatibility error. If the statements + # above are incorrectly marked unreachable, no error is reported here. + movie["other3"] = 1 + + class MovieBase(TypedDict, extra_items=ReadOnly[str | None]): name: str diff --git a/packages/pyright-internal/src/tests/typeEvaluator5.test.ts b/packages/pyright-internal/src/tests/typeEvaluator5.test.ts index bf35f9c0e94b..e929c80160b4 100644 --- a/packages/pyright-internal/src/tests/typeEvaluator5.test.ts +++ b/packages/pyright-internal/src/tests/typeEvaluator5.test.ts @@ -350,7 +350,7 @@ test('TypedDictReadOnly2', () => { test('TypedDictClosed1', () => { const analysisResults = TestUtils.typeAnalyzeSampleFiles(['typedDictClosed1.py']); - TestUtils.validateResults(analysisResults, 7); + TestUtils.validateResults(analysisResults, 8); }); test('TypedDictClosed2', () => { From f379f89e1ee7d6ffc041e4e6f31688be61999e9b Mon Sep 17 00:00:00 2001 From: nileshpatil6 Date: Mon, 10 Aug 2026 09:58:32 +0530 Subject: [PATCH 2/2] Add guard test for negative 'in' check on a required key --- .../src/tests/samples/typedDictClosed1.py | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/packages/pyright-internal/src/tests/samples/typedDictClosed1.py b/packages/pyright-internal/src/tests/samples/typedDictClosed1.py index bd978cf1549b..ed6d79723730 100644 --- a/packages/pyright-internal/src/tests/samples/typedDictClosed1.py +++ b/packages/pyright-internal/src/tests/samples/typedDictClosed1.py @@ -48,6 +48,17 @@ def func2(movie: Movie) -> None: movie["other3"] = 1 +def func3(movie: Movie) -> None: + # "name" is a required known item, so it is always present and the + # negative branch of the "in" check must remain unreachable. + if "name" in movie: + return + + # This would generate a type incompatibility error if the statement + # above were incorrectly considered reachable. + movie["other4"] = 1 + + class MovieBase(TypedDict, extra_items=ReadOnly[str | None]): name: str