From 7f1b6c21261b90c633e1b1e9926b90b1dd4ecc84 Mon Sep 17 00:00:00 2001 From: Henry Su Date: Tue, 11 Aug 2026 14:56:36 -0500 Subject: [PATCH 1/2] Fix negative type narrowing for tuple membership with class objects --- .../src/analyzer/typeGuards.ts | 2 ++ .../src/tests/checker.test.ts | 5 +++++ .../tests/samples/typeNarrowingContainer1.py | 20 +++++++++++++++++++ 3 files changed, 27 insertions(+) create mode 100644 packages/pyright-internal/src/tests/samples/typeNarrowingContainer1.py diff --git a/packages/pyright-internal/src/analyzer/typeGuards.ts b/packages/pyright-internal/src/analyzer/typeGuards.ts index 82f2353e0f72..09a2c581c856 100644 --- a/packages/pyright-internal/src/analyzer/typeGuards.ts +++ b/packages/pyright-internal/src/analyzer/typeGuards.ts @@ -2146,6 +2146,8 @@ function narrowTypeForContainerType( typesToEliminate.push(tupleEntry.type); } else if (isClassInstance(tupleEntry.type) && isLiteralType(tupleEntry.type)) { typesToEliminate.push(tupleEntry.type); + } else if (isInstantiableClass(tupleEntry.type)) { + typesToEliminate.push(tupleEntry.type); } } }); diff --git a/packages/pyright-internal/src/tests/checker.test.ts b/packages/pyright-internal/src/tests/checker.test.ts index 75c25ecc617b..b77f31987886 100644 --- a/packages/pyright-internal/src/tests/checker.test.ts +++ b/packages/pyright-internal/src/tests/checker.test.ts @@ -721,3 +721,8 @@ test('Deprecated8', () => { const analysisResults2 = TestUtils.typeAnalyzeSampleFiles(['deprecated8.py'], configOptions); TestUtils.validateResults(analysisResults2, 4); }); + +test('TypeNarrowingContainer1', () => { + const analysisResults = TestUtils.typeAnalyzeSampleFiles(['typeNarrowingContainer1.py']); + TestUtils.validateResults(analysisResults, 0); +}); diff --git a/packages/pyright-internal/src/tests/samples/typeNarrowingContainer1.py b/packages/pyright-internal/src/tests/samples/typeNarrowingContainer1.py new file mode 100644 index 000000000000..0e338ce019d2 --- /dev/null +++ b/packages/pyright-internal/src/tests/samples/typeNarrowingContainer1.py @@ -0,0 +1,20 @@ +# This sample tests negative type narrowing for tuple membership checks (in / not in) +# containing instantiable class objects (type[T]). + +from typing_extensions import assert_type + +class ClassA: pass +class ClassB: pass +class ClassC: pass + +def test_in_class_tuple(x: type[ClassA] | type[ClassB] | type[ClassC]): + if x in (ClassA, ClassB): + assert_type(x, type[ClassA] | type[ClassB]) + else: + assert_type(x, type[ClassC]) + +def test_not_in_class_tuple(x: type[ClassA] | type[ClassB] | type[ClassC]): + if x not in (ClassA, ClassB): + assert_type(x, type[ClassC]) + else: + assert_type(x, type[ClassA] | type[ClassB]) From 931274777589ebec359c619960947576f83ceb94 Mon Sep 17 00:00:00 2001 From: Henry Su Date: Tue, 11 Aug 2026 16:32:10 -0500 Subject: [PATCH 2/2] Restrict negative tuple membership elimination of instantiable classes to final classes --- .../src/analyzer/typeGuards.ts | 9 +++-- .../tests/samples/typeNarrowingContainer1.py | 34 ++++++++++++++----- 2 files changed, 32 insertions(+), 11 deletions(-) diff --git a/packages/pyright-internal/src/analyzer/typeGuards.ts b/packages/pyright-internal/src/analyzer/typeGuards.ts index 09a2c581c856..d4e3a2e6ea68 100644 --- a/packages/pyright-internal/src/analyzer/typeGuards.ts +++ b/packages/pyright-internal/src/analyzer/typeGuards.ts @@ -2137,8 +2137,11 @@ function narrowTypeForContainerType( return referenceType; } - // Determine which tuple types can be eliminated. Only "None" and - // literal types can be handled here. + // Determine which tuple types can be eliminated. Only "None", + // literal types, and final instantiable classes can be handled here. + // Non-final instantiable classes (type[A]) cannot be eliminated in negative + // tests because a subclass SubA(A) reaches the negative branch at runtime + // (since SubA != A). const typesToEliminate: Type[] = []; containerType.priv.tupleTypeArgs.forEach((tupleEntry) => { if (!tupleEntry.isUnbounded) { @@ -2146,7 +2149,7 @@ function narrowTypeForContainerType( typesToEliminate.push(tupleEntry.type); } else if (isClassInstance(tupleEntry.type) && isLiteralType(tupleEntry.type)) { typesToEliminate.push(tupleEntry.type); - } else if (isInstantiableClass(tupleEntry.type)) { + } else if (isInstantiableClass(tupleEntry.type) && ClassType.isFinal(tupleEntry.type)) { typesToEliminate.push(tupleEntry.type); } } diff --git a/packages/pyright-internal/src/tests/samples/typeNarrowingContainer1.py b/packages/pyright-internal/src/tests/samples/typeNarrowingContainer1.py index 0e338ce019d2..a1961ffcadfb 100644 --- a/packages/pyright-internal/src/tests/samples/typeNarrowingContainer1.py +++ b/packages/pyright-internal/src/tests/samples/typeNarrowingContainer1.py @@ -1,20 +1,38 @@ # This sample tests negative type narrowing for tuple membership checks (in / not in) # containing instantiable class objects (type[T]). +from typing import final from typing_extensions import assert_type -class ClassA: pass -class ClassB: pass +@final +class FinalClassA: pass + +@final +class FinalClassB: pass + class ClassC: pass -def test_in_class_tuple(x: type[ClassA] | type[ClassB] | type[ClassC]): - if x in (ClassA, ClassB): - assert_type(x, type[ClassA] | type[ClassB]) +class NonFinalClassA: pass +class SubA(NonFinalClassA): pass + + +def test_in_final_class_tuple(x: type[FinalClassA] | type[FinalClassB] | type[ClassC]): + if x in (FinalClassA, FinalClassB): + assert_type(x, type[FinalClassA] | type[FinalClassB]) else: assert_type(x, type[ClassC]) -def test_not_in_class_tuple(x: type[ClassA] | type[ClassB] | type[ClassC]): - if x not in (ClassA, ClassB): +def test_not_in_final_class_tuple(x: type[FinalClassA] | type[FinalClassB] | type[ClassC]): + if x not in (FinalClassA, FinalClassB): assert_type(x, type[ClassC]) else: - assert_type(x, type[ClassA] | type[ClassB]) + assert_type(x, type[FinalClassA] | type[FinalClassB]) + +def test_not_in_non_final_class_tuple(x: type[NonFinalClassA] | type[ClassC]): + if x not in (NonFinalClassA,): + # SubA is a subclass of NonFinalClassA. At runtime, SubA in (NonFinalClassA,) + # evaluates to False, so SubA reaches this negative branch. Therefore, + # type[NonFinalClassA] must not be eliminated when NonFinalClassA is not final. + assert_type(x, type[NonFinalClassA] | type[ClassC]) + else: + assert_type(x, type[NonFinalClassA])