Skip to content

Don't narrow a failed class pattern match against type[X] - #11782

Open
Shubham Padkonde (Shubham-Padkonde) wants to merge 2 commits into
microsoft:mainfrom
Shubham-Padkonde:fix-match-class-type-var
Open

Shubham Padkonde (Shubham-Padkonde) wants to merge 2 commits into
microsoft:mainfrom
Shubham-Padkonde:fix-match-class-type-var

Conversation

@Shubham-Padkonde

@Shubham-Padkonde Shubham Padkonde (Shubham-Padkonde) commented Sep 19, 2026 •

Copy link
Copy Markdown
Contributor

Fixes #11294

In a class pattern, the class expression can be a variable of type type[X] (or cls in a classmethod) rather than the class X itself. At runtime that value may be a subclass of X, so case subclass(...) can fail for an instance of X. pyright's negative narrowing treated the pattern as exactly X, eliminated the subject, and reported the following case as unreachable:

def f1(subclass: type[Example]) -> None:
    match Example("a"):
        case subclass(value):
            ...
        case anything:          # "Code is unreachable" (reportUnreachable)
            ...

Change

In narrowTypeBasedOnClassPattern, the negative (no-match) case no longer narrows when the pattern's class type has includeSubclasses set. That's how pyright represents a type[X] value, as opposed to the class X itself. The isinstance negative narrowing in typeGuards.ts already treats includeSubclasses filters as indeterminate for the same reason. Positive narrowing and patterns that name a class directly are unchanged.

Tests

New sample matchClass9.py, run with reportUnreachable enabled, covers:

  • the example from the issue;
  • a type[X] pattern with no arguments.

Correction: an earlier version of this description also claimed coverage for a cls() pattern in a classmethod. That was wrong — the sample never contained such a case, and it could not, because pyright rejects case cls(): outright with "type[Self@Example]" is not a class. That happens on main as well, so it is a separate limitation rather than anything this PR affects. See the discussion below.

Results:

  • It fails on main (unreachable-code errors and Never in the fallthrough case) and passes with this change.
  • All typeEvaluator1–8 suites pass, including every MatchClass* test.

This change was written with help from an AI coding assistant (Claude Code). I reviewed and tested it as described above.

🤖 Generated with Claude Code

A class pattern whose class is a type[X] value may match only a
subclass of X at runtime, so a failed match says nothing about an
instance of X. Negative narrowing treated the pattern as exactly X,
eliminated the subject and made the following case unreachable. Skip
negative narrowing when the pattern class includes subclasses, as
isinstance narrowing already does.

Fixes microsoft#11294

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@github-actions

Copy link
Copy Markdown
Contributor

Compared candidate 7c4847ac26504c9421578604ac71a0302c5be1e8 against its first parent 916d7dc62e063ba6342493e44fc55389d42d5120.

Type checker benchmark

🟢 No performance regressions detected.

Regression threshold: 20.0%
Variance guard: >1.0s time and >100.0 MB memory

Package Checker Files checked Time Time delta Peak memory Memory delta Status
ansible pyright 583 13.782s -36.4% 1172.0 MB +5.5% 🟢 Pass
click pyright 17 2.006s -31.0% 362.6 MB +0.4% 🟢 Pass
homeassistant pyright 9850 108.088s -31.7% 6123.5 MB -1.1% 🟢 Pass
numpy pyright 356 25.013s -42.4% 1650.5 MB -10.8% 🟢 Pass
pandas pyright 1459 723.400s -26.9% 4449.7 MB +0.4% 🟢 Pass
pytest pyright 243 8.487s -32.5% 881.2 MB -5.3% 🟢 Pass
requests pyright 19 1.572s -31.6% 350.1 MB +0.8% 🟢 Pass
torch pyright 2400 100.546s -34.6% 4914.3 MB +0.5% 🟢 Pass
transformers pyright 2901 83.973s -37.6% 5280.6 MB +2.9% 🟢 Pass

Pyright stats

Package Parsed/bound Checked Find Read Tokenize Parse Imports Bind Check Cycles
ansible 989 583 0.020s 0.080s 0.280s 0.480s 0.160s 0.660s 11.700s 0.000s
click 111 17 0.000s 0.010s 0.080s 0.130s 0.020s 0.180s 1.390s 0.000s
homeassistant 11147 9850 0.240s 0.780s 2.300s 3.680s 1.640s 5.420s 91.780s 0.000s
numpy 603 356 0.010s 0.050s 0.240s 0.460s 0.180s 0.570s 23.140s 0.000s
pandas 1895 1459 0.040s 0.210s 0.900s 1.410s 0.240s 1.760s 717.940s 0.000s
pytest 542 243 0.010s 0.040s 0.240s 0.330s 0.080s 0.470s 7.030s 0.000s
requests 168 19 0.000s 0.010s 0.070s 0.130s 0.040s 0.160s 0.930s 0.000s
torch 3079 2400 0.080s 0.320s 1.530s 2.150s 0.480s 3.160s 91.820s 0.000s
transformers 3537 2901 0.070s 0.310s 1.520s 2.380s 0.690s 6.490s 74.380s 0.000s

@bschnurr

Bill Schnurr (bschnurr) commented Sep 21, 2026 •

Copy link
Copy Markdown
Member

🔒 Automated review in progress — Bill Schnurr (@bschnurr) is auto-reviewing this PR.

case subclass():
reveal_type(subject, expected_text="Example")
case _:
reveal_type(subject, expected_text="Example")

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Warning · Non-blocking recommendation

📍 packages/pyright-internal/src/tests/samples/matchClass9.py:15
The PR description promises coverage for cls() inside a classmethod, but this sample contains only free-function type[Example] parameters. Add the classmethod regression case to exercise that distinct producer of subclass-inclusive class types, or correct the PR description.

[verified]

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Corrected the PR description rather than the sample — the cls() claim was false, and it could not be satisfied: pyright rejects case cls(): with "type[Self@Example]" is not a class, on clean main as well. Details and output in this comment.

@bschnurr

Copy link
Copy Markdown
Member

Result: ⚠️ needs-more-tests

Verification details

Verification: Isolated verification observed failures whose relationship to this PR could not be determined: Inferred classmethod cls pattern reproduction; this review is not fully verified.

Summary: Offline dependencies installed successfully. The targeted MatchClass Jest run passed all nine tests, including the new MatchClass9 test. However, an ad-hoc classmethod `cls()` case described by the PR failed because Pyright reported `type[Self@Example] is not a class`; the committed sample does not contain this claimed case. Verification therefore indicates missing coverage and incomplete support for the described classmethod behavior.

Test runs: 2 passed, 1 failed

  • ❌ Failed | relationship unknown | Inferred classmethod cls pattern reproduction | cat > src/tests/samples/matchClassAgent.py <<'PY'

pyright: reportUnreachable=true

class Example:
@classmethod
def check(cls, subject: "Example") -> None:
match subject:
case cls():
reveal_type(subject, expected_text="Example")
case _:
reveal_type(subject, expected_text="Example")
PY
cat > src/tests/matchClassAgent.test.ts <<'TS'
import { ConfigOptions } from '../common/configOptions';
import { pythonVersion3_10 } from '../common/pythonVersion';
import { Uri } from '../common/uri/uri';
import * as TestUtils from './testUtils';

test('ClassMethodDynamicClassPattern', () => {
const configOptions = new ConfigOptions(Uri.empty());
configOptions.defaultPythonVersion = pythonVersion3_10;
const analysisResults = TestUtils.typeAnalyzeSampleFiles(['matchClassAgent.py'], configOptions);
TestUtils.validateResults(analysisResults, 0);
});
TS
node ./node_modules/jest/bin/jest.js src/tests/matchClassAgent.test.ts --runInBand --runTestsByPath

  • ✅ Passed | MatchClass evaluator tests | node ./node_modules/jest/bin/jest.js src/tests/typeEvaluator6.test.ts --runInBand --runTestsByPath --testNamePattern='MatchClass'
  • ✅ Passed | Offline dependency bootstrap | pnpm install --offline --frozen-lockfile
❌ Inferred classmethod cls pattern reproduction diagnostic output
[7:18] "type[Self@Example]" is not a class
FAIL src/tests/matchClassAgent.test.ts
  ✕ ClassMethodDynamicClassPattern
Expected 0 errors, got 1
Test Suites: 1 failed, 1 total
Tests: 1 failed, 1 total

@bschnurr Bill Schnurr (bschnurr) left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Approved via Review Center.

@bschnurr Bill Schnurr (bschnurr) added the review-auto:approved Automated review: no blocking findings (approval posted). label Sep 21, 2026
@Shubham-Padkonde

Copy link
Copy Markdown
Contributor Author

You're right, and I've corrected the PR description rather than the sample — the cls() claim was simply false. matchClass9.py contains only func1 and func2, both taking a free-function type[Example] parameter. I should not have listed a third case that was never written.

On adding it instead: it can't be added, because pyright rejects that pattern outright. Running your reproduction:

class Example:
    @classmethod
    def check(cls, subject: "Example") -> None:
        match subject:
            case cls():
                reveal_type(subject, expected_text="Example")
            case _:
                reveal_type(subject, expected_text="Example")

on this branch:

ERROR line 8: "type[Self@Example]" is not a class
errors=1

and on clean main:

ERROR line 8: "type[Self@Example]" is not a class
MAIN errors=1

So type[Self] is not accepted as a class pattern at all, independently of the negative-narrowing change here. That is a distinct gap — the pattern never reaches the narrowing code this PR touches — and I did not want to fold a fix for it into this PR. Happy to open a separate issue for it if you think it is worth tracking; it looks related to #11294 in spirit but sits in the class-expression validation rather than in narrowTypeBasedOnClassPattern.

The two cases the sample does cover still fail on main and pass here, so the change itself is unaffected by this correction.


Disclosure: this investigation was performed by Claude Code (Claude Opus 5) working as my agent, at my direction. The output quoted above comes from real runs in my local environment; I am accountable for this PR.

@heejaechang

Heejae Chang (heejaechang) commented Sep 22, 2026 •

Copy link
Copy Markdown
Collaborator

🔴 Pyright CLI QA — 583f4d4f — verdict: red

Automated CLI checks compared this change with its base revision.
15 focused check(s) completed.

Needs review: Diagnostics changed for this example

The base and PR report different diagnostics for the same example. Is this change intended?

Reproduction and observed results

Create these files in an empty directory:

main.py

from enum import Enum
from typing import assert_type

class Color(Enum):
    RED = 1

def describe(value: Color | str, pattern: type[Color]) -> str:
    match value:
        case pattern():
            return "color"
        case _:
            # Enums with members cannot have subclasses, so Color was matched.
            reveal_type(value)
            assert_type(value, str)
            return value

pyrightconfig.json

{
  "include": [
    "."
  ],
  "pythonVersion": "3.13",
  "typeCheckingMode": "strict",
  "reportUnnecessaryComparison": "none"
}

From that directory, run each built Pyright revision:

node <pyright-cli-entrypoint> --outputjson -p .

Base 916d7dc62e063ba6342493e44fc55389d42d5120

{
  "exit_code": 0,
  "diagnostics": [
    {
      "file": "main.py",
      "severity": "information",
      "rule": null,
      "message": "Type of \"value\" is \"str\"",
      "range": {
        "start": [
          12,
          24
        ],
        "end": [
          12,
          29
        ]
      }
    }
  ]
}

PR 583f4d4ff9f8ce52e1ee0701fcbfbc9908271476

{
  "exit_code": 1,
  "diagnostics": [
    {
      "file": "main.py",
      "severity": "error",
      "rule": "reportReturnType",
      "message": "Type \"Color | str\" is not assignable to return type \"str\"\n  Type \"Color | str\" is not assignable to type \"str\"\n    \"Color\" is not assignable to \"str\"",
      "range": {
        "start": [
          14,
          19
        ],
        "end": [
          14,
          24
        ]
      }
    },
    {
      "file": "main.py",
      "severity": "information",
      "rule": null,
      "message": "Type of \"value\" is \"Color | str\"",
      "range": {
        "start": [
          12,
          24
        ],
        "end": [
          12,
          29
        ]
      }
    },
    {
      "file": "main.py",
      "severity": "error",
      "rule": "reportAssertTypeFailure",
      "message": "\"assert_type\" mismatch: expected \"str\" but received \"Color | str\"",
      "range": {
        "start": [
          13,
          24
        ],
        "end": [
          13,
          29
        ]
      }
    }
  ]
}

Diagnostic ranges use zero-based line and character positions.

@Shubham-Padkonde

Copy link
Copy Markdown
Contributor Author

The new CLI QA report flags a potential behavior difference, but does not include a reproducer, expected/actual diagnostics, or the checks that could not complete. Could you share those details so I can reproduce the finding and add a targeted regression? I am investigating the report; I am not treating the incomplete result as a pass.

Prepared with Codex assistance.

@Shubham-Padkonde

Copy link
Copy Markdown
Contributor Author

Fixed final-class negative narrowing in 583f4d4. A type[X] pattern remains conservative when X can have subclasses, but uses normal negative narrowing when X is final (including bool). Added the supplied final-class and bool fallthrough cases: they fail before the fix and all 159 typeEvaluator6 tests pass afterward. TypeScript compilation, ESLint, formatting and git diff --check also pass.

Prepared and tested with Codex assistance.

@github-actions

Copy link
Copy Markdown
Contributor

Compared candidate 00be083b2abe3adf2f2d4de0265bbe8ca893b391 against its first parent 16159dcfbe0c76d24d560973dd9ac831a77213db.

View the full release history charts with the base and PR results.

Execution time history

Execution time history

Peak memory history

Peak memory history

Download the chart artifact.

Type checker benchmark

🟢 No performance regressions detected.

Regression threshold: 20.0%
Statistic: median
Variance guard: >1.0s time and >100.0 MB memory

Package Checker Files checked Time Time delta Peak memory Memory delta Status
ansible pyright 583 17.960s +1.9% 1085.3 MB -0.4% 🟢 Pass
click pyright 17 2.919s -3.2% 355.1 MB +0.4% 🟢 Pass
homeassistant pyright 9850 88.956s +0.3% 6127.6 MB +0.7% 🟢 Pass
numpy pyright 356 21.997s +9.8% 1383.7 MB +2.2% 🟢 Pass
pandas pyright 1459 917.379s -1.8% 4441.2 MB -0.4% 🟢 Pass
pytest pyright 243 13.021s +0.1% 845.2 MB -0.1% 🟢 Pass
requests pyright 19 2.233s +0.5% 346.0 MB -0.0% 🟢 Pass
torch pyright 2400 129.335s -4.6% 4923.7 MB +4.2% 🟢 Pass
transformers pyright 2901 91.989s +9.4% 5020.8 MB -4.3% 🟢 Pass

Pyright stats

Package Parsed/bound Checked Find Read Tokenize Parse Imports Bind Check Cycles
ansible 989 583 0.030s 0.097s 0.393s 0.640s 0.203s 0.897s 14.850s 0.000s
click 111 17 0.000s 0.013s 0.120s 0.220s 0.037s 0.267s 1.977s 0.000s
homeassistant 11147 9850 0.233s 0.700s 2.077s 3.643s 1.590s 5.380s 76.403s 0.000s
numpy 603 356 0.010s 0.050s 0.247s 0.493s 0.167s 0.653s 19.910s 0.000s
pandas 1895 1459 0.060s 0.243s 1.037s 2.347s 0.297s 2.860s 912.797s 0.000s
pytest 542 243 0.020s 0.053s 0.367s 0.553s 0.103s 0.767s 10.690s 0.000s
requests 168 19 0.000s 0.010s 0.120s 0.210s 0.057s 0.240s 1.273s 0.000s
torch 3079 2400 0.140s 0.383s 1.877s 3.487s 0.603s 4.360s 118.607s 0.000s
transformers 3537 2901 0.090s 0.313s 1.723s 3.093s 0.730s 7.167s 77.927s 0.000s

This branch was successfully deployed

1 active deployment
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.

False unreachable warning when matching type[X]

3 participants