python / python/mypy

Mypy doesn't narrow a matched object's type (generic or not) based on attribute subpattern match

オープン
#19,081 コメント 3 件 リアクション 2 件 担当者 0 名 GitHub で見る

まだ誰も着手していません。

bug topic-match-statement topic-type-narrowing
主要言語
Python
スター
20.6k
フォーク
3.3k
PR マージ指標
PR 指標を取得中

説明

Bug Report

Summary

TLDR

Mypy doesn't narrow a parent object's type when subpattern-matching on one of its attributes.

Longer summary

Mypy doesn't always use attribute sub-pattern matches (e.g. case Err(ValueError())) to narrow the parent object (e.g. Err[ValueError | AttributeError] isn't narrowed to Err[ValueError]). This prevents an error-free type-level exhaustiveness check using assert_never after matching all possible patterns.

Some of the examples below show that mypy successfully narrows the attribute itself, but doesn't propagate this narrowing up to the object being matched, even when there's a generic type argument that could be narrowed. E.g. if val is narrowed to ValueError, mypy should be able to narrow the object from Err[ValueError | AttributeError] to Err[ValueError].

Ideally, in the case _ after exhausting all patterns/subpatterns, the object could be narrowed to Never.

It's possible that it's expected that mypy can't narrow a non-generic type, if it needs a type argument that can explicitly be narrowed. I've included a non-generic example below anyway though, for completeness, since it does have all its patterns matched but fails the exhaustiveness check.

Context

The real-world context for this issue was an attempt to pattern match on poltergeist's Result = Ok[_T] | Err[_E] type, where Err can be constrained to a specific subset of exceptions. Finishing a match result: statement with case _: assert_never(result) only works if we avoid matching error sub-patterns: i.e. if we do case Err(err) and avoid case Err(ValueError()).

In this context, this issue takes away from some of the potential power of a library like poltergeist, which seeks to make error handling more explicit and type-safe.

I guess a workaround could be to just add a nested match statement on err._value itself within the case Err(err) block. But that feels unfortunate to have to do when match was built to be powerful around subpattern matching, and PEP 636 – Structural Pattern Matching: Tutorial states that "Patterns can be nested within each other" (which is the case here, it's just the type-checking that doesn't use all the type info it has).

To Reproduce

Example 1: Result (generic)

Here, I'd expect mypy to narrow the type of result, e.g. to Err[ValueError] inside the case Err(ValueError() as val) block, and to Never inside the case _ block.

SuccessType = TypeVar("SuccessType")
FailureType = TypeVar("FailureType")


class Ok(Generic[SuccessType]):
    __match_args__ = ("_value",)

    def __init__(self, value: SuccessType) -> None:
        self._value = value


class Err(Generic[FailureType]):
    __match_args__ = ("_value",)

    def __init__(self, value: FailureType) -> None:
        self._value = value


Result = Ok[SuccessType] | Err[FailureType]


def handle_result(result: Result[str, ValueError | AttributeError]) -> None:
    match result:
        case Ok(success_value):
            # Revealed type is "builtins.str" Mypy
            reveal_type(success_value)
            # Revealed type is "Ok[builtins.str]" Mypy
            reveal_type(result)
        case Err(ValueError() as val):
            # Revealed type is "builtins.ValueError" Mypy
            reveal_type(val)
            # Revealed type is "Err[Union[builtins.ValueError, builtins.AttributeError]]" Mypy
            reveal_type(result)
        case Err(AttributeError()):
            # Revealed type is "Err[Union[builtins.ValueError, builtins.AttributeError]]" Mypy
            reveal_type(result)
        case _:
            # Argument 1 to "assert_never" has incompatible type "Err[ValueError | AttributeError]"; expected "Never" Mypy(arg-type)
            assert_never(result)
Example 2: NonGenericErr (non-generic)

Here, I narrowed the scope to matching on the error class, and made it non-generic. Even here, mypy can narrow the attribute (val: ValueError), but not the object (err: NonGenericErr and err._value: ValueError | AttributeError). And doesn't realize in the case _ block that we've already exhausted all patterns above.

class NonGenericErr:
    __match_args__ = ("_value",)

    def __init__(self, value: ValueError | AttributeError) -> None:
        self._value = value


def handle_non_generic_err(err: NonGenericErr) -> None:
    # Revealed type is "NonGenericErr" Mypy
    reveal_type(err)
    match err:
        case NonGenericErr(ValueError() as val):
            # Revealed type is "builtins.ValueError" Mypy
            reveal_type(val)
            # Revealed type is "Union[builtins.ValueError, builtins.AttributeError]" Mypy
            reveal_type(err._value)
            # Revealed type is "NonGenericErr" Mypy
            reveal_type(err)
        case NonGenericErr(AttributeError()):
            # Revealed type is "NonGenericErr" Mypy
            reveal_type(err)
        case _:
            # Argument 1 to "assert_never" has incompatible type "NonGenericErr"; expected "Never" Mypy(arg-type)
            assert_never(err)
Example 3: FailureResult (generic, dataclass)

I could see the logic that Example 2 is constrained by the lack of a generic type for mypy to use to narrow err beyond NonGenericErr. But even if we add that back, mypy can't narrow err as expected within any of the case blocks.

This example is basically a trimmed down / tighter-scoped version of Example 1.

from dataclasses import dataclass


@dataclass
class FailureResult[ErrorType]:
    error: ErrorType


def handle_failure_result(failure_result: FailureResult[ValueError | AttributeError]) -> None:
    match failure_result:
        case FailureResult(ValueError() as error):
            # Revealed type is "builtins.ValueError" Mypy
            reveal_type(error)
            # Revealed type is "Union[builtins.ValueError, builtins.AttributeError]" Mypy
            reveal_type(failure_result.error)
            # Revealed type is "FailureResult[Union[builtins.ValueError, builtins.AttributeError]]" Mypy
            reveal_type(failure_result)
        case FailureResult(AttributeError()):
            # Revealed type is "FailureResult[Union[builtins.ValueError, builtins.AttributeError]]" Mypy
            reveal_type(failure_result)
        case _:
            # Argument 1 to "assert_never" has incompatible type "FailureResult[ValueError | AttributeError]"; expected "Never" Mypy(arg-type)
            assert_never(failure_result)

Expected Behavior

Mypy successfully narrows the type of an object we're pattern matching on, based on how we've matched on its attribute, allowing for an exhaustive match statement ending with assert_never if we have indeed exhausted all possible patterns.

Actual Behavior

See errors and unexpected reveal_type outputs above

Your Environment

  • Mypy version used: mypy 1.15.0
  • Mypy configuration options from mypy.ini (and other config files):
`mypy.ini`
[mypy]
python_version = 3.13
mypy_path = typings
ignore_missing_imports = True
check_untyped_defs = True
disallow_untyped_defs = True
disallow_untyped_calls = True
strict_equality = True
disallow_any_unimported = True
warn_return_any = True
no_implicit_optional = True
pretty = True
show_error_context = True
show_error_codes = True
show_error_code_links = True
no_namespace_packages = True
- Python version used: 3.13

Thanks for all the work done to make mypy what it is!

コントリビューションガイド

コントリビューションガイドを開く

はじめの一歩

  1. issue を最後まで読み、次にプロジェクトのコントリビューションガイドを読みます。
  2. 着手することを issue にコメントします — 二人が同じ作業をするのを防げます。
  3. リポジトリをフォークし、ブランチを切って変更します。
  4. issue 番号を参照したプルリクエストを送ります。

調査の方向性

まず、mypy 1.15.0 で3つの再現ケースを実行し、issue で説明されている reveal_type の出力と assert_never のエラーを比較します。これらのケースから、構造的パターンマッチングの narrowing の挙動を追跡します。親オブジェクトが属性サブパターンのマッチを反映し、網羅的な例が報告されたエラーなしで通過すれば完了です。

索引モデルが issue の本文から書いたものです。

評価

技術スタック
python
領域
devtools
issue の種類
バグ
難易度
4/5
見積もり時間
3〜5日
活発さ
停滞
明瞭さ
おおむね明確
初心者へのやさしさ
42/100

新しい issue をメールで受け取る

初心者向けの GitHub issue を短くまとめたダイジェスト。