From 15a37cff355659e738fd7d7b2a1934901cf9af09 Mon Sep 17 00:00:00 2001 From: Rich Chiodo Date: Mon, 15 Jun 2026 09:57:05 -0700 Subject: [PATCH] Fix incorrect `is` narrowing for NewType instances against None/bool literals A NewType instance is, at runtime, an instance of its base type. When narrowing `x is None` or `x is ` for a NewType-typed value, the assignability check used the NewType subclass, so the base type (e.g. None or bool) was not considered assignable and the branch was incorrectly marked unreachable. Unwrap NewType instances to their underlying base type (recursively for nested NewTypes) when testing for None/literal overlap in narrowTypeForIsNone and narrowTypeForLiteralComparison. Fixes https://github.com/microsoft/pyright/issues/11463 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../src/analyzer/typeGuards.ts | 38 +++++++++++++++++-- .../src/tests/samples/newType8.py | 38 +++++++++++++++++++ .../src/tests/typeEvaluator2.test.ts | 6 +++ 3 files changed, 79 insertions(+), 3 deletions(-) create mode 100644 packages/pyright-internal/src/tests/samples/newType8.py diff --git a/packages/pyright-internal/src/analyzer/typeGuards.ts b/packages/pyright-internal/src/analyzer/typeGuards.ts index 9e514cb467bb..aa40b95e2b1e 100644 --- a/packages/pyright-internal/src/analyzer/typeGuards.ts +++ b/packages/pyright-internal/src/analyzer/typeGuards.ts @@ -1081,6 +1081,31 @@ function narrowTupleTypeForIsNone(evaluator: TypeEvaluator, type: Type, isPositi }); } +// At runtime, a NewType instance is simply an instance of its base type. +// If the provided type is an instance of a NewType class, this returns the +// underlying base type instance (recursively unwrapping nested NewTypes). +// Otherwise, it returns undefined. +function getNewTypeBaseInstance(type: Type): ClassType | undefined { + if (!isClassInstance(type)) { + return undefined; + } + + let current: ClassType = type; + let found = false; + + while (ClassType.isNewTypeClass(current) && current.shared.baseClasses.length > 0) { + const baseClass = current.shared.baseClasses[0]; + if (!isInstantiableClass(baseClass)) { + break; + } + + current = baseClass; + found = true; + } + + return found ? ClassType.cloneAsInstance(current) : undefined; +} + // Handle type narrowing for expressions of the form "x is None" and "x is not None". function narrowTypeForIsNone(evaluator: TypeEvaluator, type: Type, isPositiveTest: boolean) { const expandedType = mapSubtypes(type, (subtype) => { @@ -1134,8 +1159,11 @@ function narrowTypeForIsNone(evaluator: TypeEvaluator, type: Type, isPositiveTes return isPositiveTest ? adjustedSubtype : undefined; } - // Is it potentially None? - if (evaluator.assignType(subtype, evaluator.getNoneType())) { + // Is it potentially None? For NewType instances, the runtime value + // is an instance of the underlying base type, so test the base type + // for overlap with None. + const subtypeForNoneCheck = getNewTypeBaseInstance(subtype) ?? subtype; + if (evaluator.assignType(subtypeForNoneCheck, evaluator.getNoneType())) { resultIncludesNoneSubtype = true; return isPositiveTest ? addConditionToType(evaluator.getNoneType(), subtype.props?.condition) @@ -2741,7 +2769,11 @@ function narrowTypeForLiteralComparison( } if (isIsOperator || isNoneInstance(subtype)) { - const isSubtype = evaluator.assignType(subtype, literalType); + // For NewType instances, the runtime value is an instance of the + // underlying base type, so test the base type for overlap with + // the literal. + const subtypeForCheck = getNewTypeBaseInstance(subtype) ?? subtype; + const isSubtype = evaluator.assignType(subtypeForCheck, literalType); return isSubtype ? literalType : undefined; } } diff --git a/packages/pyright-internal/src/tests/samples/newType8.py b/packages/pyright-internal/src/tests/samples/newType8.py new file mode 100644 index 000000000000..8c084f261957 --- /dev/null +++ b/packages/pyright-internal/src/tests/samples/newType8.py @@ -0,0 +1,38 @@ +# This sample tests narrowing of NewType instances using the "is" operator +# against None and bool literals. At runtime, a NewType instance is simply +# an instance of its base type, so these comparisons can succeed and the +# narrowed branches are reachable. + +# pyright: reportUnreachable=true + +from types import NoneType +from typing import NewType + +Apple = NewType("Apple", NoneType) +Banana = NewType("Banana", bool) +Cherry = NewType("Cherry", int) + +# A NewType whose base is itself a NewType (recursive base). +Date = NewType("Date", Apple) + + +def f(a: Apple, b: Banana, c: Cherry, d: Date) -> None: + if a is None: + reveal_type(a, expected_text="None") + + if b is True: + reveal_type(b, expected_text="Literal[True]") + + if c is False: + reveal_type(c, expected_text="Literal[False]") + + if d is None: + reveal_type(d, expected_text="None") + + +def g(c: Cherry) -> None: + # A NewType based on int cannot overlap with None, so this branch + # remains unreachable. + if c is None: + # This should generate an error because the code is unreachable. + reveal_type(c) diff --git a/packages/pyright-internal/src/tests/typeEvaluator2.test.ts b/packages/pyright-internal/src/tests/typeEvaluator2.test.ts index 9a5afa94b7e0..7cb765840a19 100644 --- a/packages/pyright-internal/src/tests/typeEvaluator2.test.ts +++ b/packages/pyright-internal/src/tests/typeEvaluator2.test.ts @@ -305,6 +305,12 @@ test('NewType7', () => { TestUtils.validateResults(analysisResults, 2); }); +test('NewType8', () => { + const analysisResults = TestUtils.typeAnalyzeSampleFiles(['newType8.py']); + + TestUtils.validateResults(analysisResults, 1); +}); + test('isInstance1', () => { const analysisResults = TestUtils.typeAnalyzeSampleFiles(['isinstance1.py']);