Skip to content

Fix type narrowing for type(x) expressions with type[T] parameters and reverse comparisons - #11619

Open
Henry Su (hsusul) wants to merge 3 commits into
microsoft:mainfrom
hsusul:fix/new-pyright-bug-fix
Open

Fix type narrowing for type(x) expressions with type[T] parameters and reverse comparisons#11619
Henry Su (hsusul) wants to merge 3 commits into
microsoft:mainfrom
hsusul:fix/new-pyright-bug-fix

Conversation

@hsusul

Copy link
Copy Markdown
Contributor

Summary

Fixes a type narrowing issue where binary comparison type guards involving type(x) expressions failed to narrow types when:

  1. type(x) appeared on the right-hand side of the comparison (e.g. if cls is type(x): or if int is type(x):).
  2. The comparison operand was a variable or parameter of type type[T] (e.g. cls: type[int]).

Root Cause

  1. Pyright's binary type guard logic for type(x) previously checked only testExpression.d.leftExpr.nodeType === ParseNodeType.Call. When type(x) was on the RHS, Pyright skipped generating a type guard callback.
  2. When comparing type(x) against a variable cls of type type[T], getTypeOfExpression(cls) returns a ClassType instance of type. Pyright checked isInstantiableClass(expandedSubtype), which returned false for type[T] instances, preventing classTypes from being populated.
  3. Positive narrowing in narrowTypeForTypeIs did not eliminate disjoint subtypes when comparing against class types derived from type[T].

Changes

  • Supported type(x) on either leftExpr or rightExpr of is, is not, ==, and != binary comparison operators.
  • Unwrapped type[T] instances (ClassType built-in type with typeArgs) to instantiable class types (convertToInstantiable(typeArgs[0], false)).
  • Updated positive narrowing logic in narrowTypeForTypeIs to check subclass overlap and eliminate disjoint subtypes.
  • Excluded type[object] operands to preserve correct behavior when comparing against generic object instances.
  • Added regression tests in typeIs6.py covering all type(x) comparison forms, type[T] parameters, @final classes, and union types.

…d reverse comparisons

- Support type(x) calls on either left or right hand side of binary comparison operators.
- Unwrap instantiable class types from type[T] expressions when evaluating type(x) type guards.
- Correctly eliminate disjoint subtypes in positive type(x) narrowing.
- Add comprehensive regression test suite in typeIs6.py.
@StellaHuang95

Stella Huang (StellaHuang95) commented Aug 11, 2026

Copy link
Copy Markdown
Collaborator

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

@StellaHuang95

Copy link
Copy Markdown
Collaborator

Negative type(...) is not ... narrowing can eliminate valid supertypes, producing unsound results. The PR needs this corrected and covered by a regression test.

@StellaHuang95

Copy link
Copy Markdown
Collaborator

The negative narrowing change can incorrectly eliminate a valid superclass subtype when the compared class is a final subclass, producing unsound results.

@StellaHuang95

Copy link
Copy Markdown
Collaborator

The negative narrowing change can incorrectly eliminate a non-final base class when the compared class is a final subclass, producing an unsound narrowed type.

@StellaHuang95 Stella Huang (StellaHuang95) added the review-auto:changes-requested Automated review: posted blocking findings to address. label Aug 11, 2026
…ainst final subclasses

- Restore ClassType.isFinal(instantiableSubtype) check in negative type(x) narrowing to prevent unsoundly eliminating non-final superclass subtypes when comparing against final subclasses.
- Add regression test func8 in typeIs6.py covering non-final Base class compared with @Final FinalSub.
@hsusul

Copy link
Copy Markdown
Contributor Author

Thank you for the review and sharp catch Stella Huang (@StellaHuang95)!

I have updated the negative narrowing logic in narrowTypeForTypeIs to check ClassType.isFinal(instantiableSubtype) rather than ClassType.isFinal(classType). This ensures that a non-final base class (such as Base) is preserved when comparing type(x) is not FinalSub against a @final subclass (FinalSub), preserving sound type narrowing when x is an instance of Base.

I have pushed the fix and added a regression test (func8) in typeIs6.py covering this exact pattern:

class Base: pass

@final
class FinalSub(Base): pass

def func8(x: Base):
    if type(x) is not FinalSub:
        reveal_type(x, expected_text="Base")
    else:
        reveal_type(x, expected_text="FinalSub")

@StellaHuang95

Copy link
Copy Markdown
Collaborator

GitHub cannot anchor PR review comments to unchanged lines in the diff. Falling back to a general PR comment for packages/pyright-internal/src/analyzer/typeGuards.ts:L2626.

Info · Optional note

classType is always created with includeSubclasses set to false at its sole call site, so the first disjunct always short-circuits and isSubclass cannot affect narrowing. Please remove this inert condition and computation, or add a reachable subclass-inclusive case with a comment explaining the intended future behavior.

};
}
}
}

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.

Warning · Non-blocking recommendation

The blanket object exclusion also changes direct type(x) is object checks from narrowing the positive branch to Never to performing no narrowing. It also disables the entire callback for type[int] | type[object], losing the useful int narrowing. Please scope the exclusion to the unwrapped type[object] case, or add tests documenting that this broader precision loss is intentional.

Comment thread packages/pyright-internal/src/tests/samples/typeIs6.py

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 review-auto:approved Automated review: no blocking findings (approval posted). and removed review-auto:changes-requested Automated review: posted blocking findings to address. labels Aug 11, 2026
@github-actions

This comment has been minimized.

@rchiodo

Copy link
Copy Markdown
Collaborator

I took a closer look at the mypy_primer differences. I don't think all of them are expected, and I would not merge this as-is.

The main issue is that the new logic converts the argument of type[T] with includeSubclasses=false. A value annotated type[T] can be a class object for a subclass of T unless T is final, so treating it as exactly T is unsound.

  • Spark: This looks like a real regression. In MultiIndex.symmetric_difference, other is annotated as Index, and the code checks if type(self) is not type(other): raise. The continuation is reachable when other is a MultiIndex, but this PR narrows self to Never, producing the two new errors.
  • koda-validate: The three new errors are false positives. The reverse comparison is now recognized, but narrowing loses the correlation between the constrained ExactMatchT and val after type(self.match) == type(val).
  • yarl: The removed error is an expected and desirable improvement. if type(val) is cls: return val should narrow val sufficiently for the URL return type.
  • SymPy: The large cascade appears to be primer/inference noise rather than a reproducible semantic difference from this PR. I would rerun primer rather than accept or update expectations based on that block.

I recommend preserving subclass inclusion for operands typed type[T] (using exact-class treatment only for direct class expressions or final classes), and adding a Spark-shaped regression test that verifies the continuation does not become Never. It would also be useful to distinguish Base from cls: type[Base] in the tests.

@rchiodo
Rich Chiodo (rchiodo) dismissed Stella Huang (StellaHuang95)’s stale review August 11, 2026 21:02

Mypy_primer differences should be addressed first

…ards

- Preserve includeSubclasses = true when unwrapping operands typed type[T] (e.g. cls: type[Index] or type(other)) to avoid unsoundly narrowing type(self) is not type(other) when self is a subclass of other (resolving Spark MultiIndex.symmetric_difference regression).
- Reserve exact-class treatment (includeSubclasses = false) for direct class expressions (e.g. Base, int) and @Final classes.
- Add regression tests test_spark_regression and test_direct_class_vs_type_param in typeIs6.py.
@hsusul

Copy link
Copy Markdown
Contributor Author

Thank you for the detailed mypy_primer analysis and architectural guidance!

I have updated the binary type guard unwrapping logic in getTypeNarrowingCallback:

  1. Operands typed type[T] (e.g. cls: type[Index] or type(other) where other: Index): We preserve subclass inclusion (includeSubclasses = true) when converting type[T]. This ensures that for type(self) is not type(other) (where self: MultiIndex and other: Index), type(other) can represent MultiIndex or any subclass of Index, so self is not unsoundly narrowed to Never on continuation (resolving the Spark MultiIndex.symmetric_difference regression).
  2. Direct Class Expressions (e.g. if type(x) is Base: or if int is type(x):): We retain exact-class treatment (includeSubclasses = false).

Added Tests in typeIs6.py

  • Spark Regression Test (test_spark_regression): Verifies self remains MultiIndex (does not become Never) after if type(self) is not type(other): pass.
  • Direct Class vs type[Base] Test (test_direct_class_vs_type_param): Verifies Base handling for direct class expressions vs cls: type[Base] parameters.

The update has been pushed in commit 94afc46b3.

@github-actions

Copy link
Copy Markdown
Contributor

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

koda-validate (https://github.com/keithasaurus/koda-validate)
+   .../projects/koda-validate/koda_validate/generic.py:161:38 - error: Argument of type "bool* | bytes* | int* | Decimal* | str* | float* | date* | datetime* | UUID* | Any" cannot be assigned to parameter "val" of type "ExactMatchT@EqualsValidator" in function "__call__"
+     Type "bool* | bytes* | int* | Decimal* | str* | float* | date* | datetime* | UUID* | Any" is not assignable to type "ExactMatchT@EqualsValidator" (reportArgumentType)
+   .../projects/koda-validate/koda_validate/generic.py:163:31 - error: Argument of type "bool* | bytes* | int* | Decimal* | str* | float* | date* | datetime* | UUID* | Any" cannot be assigned to parameter "val" of type "ExactMatchT@EqualsValidator" in function "__call__"
+     Type "bool* | bytes* | int* | Decimal* | str* | float* | date* | datetime* | UUID* | Any" is not assignable to type "ExactMatchT@EqualsValidator" (reportArgumentType)
+   .../projects/koda-validate/koda_validate/generic.py:164:24 - error: Type "tuple[Literal[True], bool* | bytes* | int* | Decimal* | str* | float* | date* | datetime* | UUID* | Any]" is not assignable to return type "_ResultTuple[ExactMatchT@EqualsValidator]"
+     Type "tuple[Literal[True], bool* | bytes* | int* | Decimal* | str* | float* | date* | datetime* | UUID* | Any]" is not assignable to type "_ResultTuple[ExactMatchT@EqualsValidator]"
+       "tuple[Literal[True], bool* | bytes* | int* | Decimal* | str* | float* | date* | datetime* | UUID* | Any]" is not assignable to "tuple[Literal[True], ExactMatchT@EqualsValidator]"
+         Tuple entry 2 is incorrect type
+           Type "bool* | bytes* | int* | Decimal* | str* | float* | date* | datetime* | UUID* | Any" is not assignable to type "ExactMatchT@EqualsValidator"
+       "tuple[Literal[True], bool* | bytes* | int* | Decimal* | str* | float* | date* | datetime* | UUID* | Any]" is not assignable to "tuple[Literal[False], Invalid]"
+         Tuple entry 1 is incorrect type
+           "Literal[True]" is not assignable to type "Literal[False]" (reportReturnType)
- 95 errors, 0 warnings, 0 informations
+ 98 errors, 0 warnings, 0 informations

spark (https://github.com/apache/spark)
+   .../projects/spark/python/pyspark/pandas/indexes/multi.py:817:45 - error: "Never" is not iterable (reportGeneralTypeIssues)
+   .../projects/spark/python/pyspark/pandas/indexes/multi.py:822:55 - error: "Never" is not iterable (reportGeneralTypeIssues)
- 33760 errors, 966 warnings, 0 informations
+ 33762 errors, 966 warnings, 0 informations

yarl (https://github.com/aio-libs/yarl)
-   .../projects/yarl/yarl/_url.py:447:20 - error: Type "str* | SplitResult* | URL* | UndefinedType*" is not assignable to return type "URL"
-     Type "str* | SplitResult* | URL* | UndefinedType*" is not assignable to type "URL"
-       "SplitResult*" is not assignable to "URL" (reportReturnType)
- 43 errors, 5 warnings, 0 informations
+ 42 errors, 5 warnings, 0 informations

};
}
}
}

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.

Warning · Non-blocking recommendation

The object exclusion now also rejects the pre-existing direct type(x) is object form. Previously it produced an impossible positive branch (Never); now isClassType becomes false and no narrowing callback is returned. It also discards useful alternatives in unions such as type[int] | type[object]. Please exclude only the parameterized type[object] case, or skip that subtype rather than disabling the entire callback.

const otherResult = evaluator.getTypeOfExpression(otherExpr);
const classTypes: ClassType[] = [];
let isClassType = true;

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

When both operands are calls, the left call is selected before checking whether its argument matches the reference. As a result, querying narrowing for x in type(y) is type(x) stops after type(y) fails the match and never considers the RHS type(x). Please fall through to the RHS call when the selected call does not match the reference.

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.

@rchiodo Rich Chiodo (rchiodo) left a comment

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.

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.

3 participants