1
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

[Python]網羅性チェック(Exhaustiveness Checking)を静的解析で担保する

Last updated at Posted at 2025-12-01

はじめに

この記事は静岡大学情報学部ITソルーション室アドベントカレンダーの2日目の記事として執筆されました。他の記事もぜひ御覧ください。

本文

Pythonで条件分岐を書くとき、全てのケースを網羅したつもりが漏れていた——そんな経験はありませんか?RustやHaskellならコンパイラが網羅性を検証してくれますが、Pythonでは実行時まで気づけません。

この記事では assert_never を使って、分岐の網羅性を静的解析で担保する方法を紹介します。

問題例:Union型の変更がコードを壊す

以下のようなコードを考えてみましょう。

class Pending:
    ...

class Approved:
    ...

OrderStatus = Pending | Approved


def to_response(status: OrderStatus) -> dict:
    if isinstance(status, Pending):
        return {"status": "pending"}
    elif isinstance(status, Approved):
        return {"status": "approved"}
    else:
        raise AssertionError(f"Unknown status: {status}")

この else 節、「ここには来ないはず」という意図で書いていますよね。Union型を網羅しているので、論理的には到達しないはずです。

ここで OrderStatus に新しい状態 Rejected を追加してみましょう。

class Rejected:
    reason: str

OrderStatus = Pending | Approved | Rejected  # ← Rejected を追加

to_response 関数は Rejected を処理していませんが、型チェッカーは何も言いません。else 節があるので、戻り値の型としては問題ないからです。

しかし、実行時に Rejected が渡されると AssertionError が飛びます。

なぜこのコードは変更に弱いのか

問題は else 節です。else は「それ以外の全て」を受け入れるので、Union型に新しいバリアントが追加されても、型チェッカーは「全ての場合を処理している」と判断してしまいます。本来なら検出すべき漏れが else によって隠蔽されているわけです。

さらに厄介なのは、Union型にバリアントを追加するたびに、それを使っている全ての分岐処理を探し出して修正しなければならない点です。どこを直すべきかを機械的に特定する手段がありません。

RustやHaskellでは、パターンマッチの網羅性(exhaustiveness)をコンパイラが検証してくれます。Union型に新しいバリアントを追加すると、それを処理していない箇所が全てコンパイルエラーになる。Pythonでも同じことができないでしょうか?

解決策:assert_never

この問題を解決するのが assert_never です。これを使えば、Union型の分岐で漏れがあったときに型チェッカーがエラーを出してくれます。

from typing import assert_never

def to_response(status: OrderStatus) -> dict:
    if isinstance(status, Pending):
        return {"status": "pending"}
    elif isinstance(status, Approved):
        return {"status": "approved"}
    elif isinstance(status, Rejected):
        return {"status": "rejected", "reason": status.reason}
    else:
        assert_never(status)

もし Rejected のケースを書き忘れると、型チェッカーが以下のようなエラーを出します。

ERROR Argument `Rejected` is not assignable to parameter `arg` 
      with type `Never` in function `typing.assert_never`

漏れている型をピンポイントで教えてくれます。これで、Union型を変更したときに修正すべき箇所が静的解析で全て洗い出せるようになります。


ここからは「なぜ assert_never で検出できるのか」という仕組みの話です。使い方だけ知りたい方は「まとめ」まで飛ばしてもらって大丈夫です。

なぜ assert_never は動くのか

ボトム型 Never

型理論には「ボトム型(bottom type)」という概念があります(参考:typing documentation)。これは 値を持たない型、つまり「空集合」を表す型です。

Pythonでは PEP 484NoReturn が「絶対に返らない関数」の戻り値型として導入されました。

from typing import NoReturn

def stop() -> NoReturn:
    raise RuntimeError("no way")

しかし実際には、NoReturn は戻り値以外の場面でもボトム型として使われるようになりました。この用途を明確にするため、Python 3.11で Never が追加されました(cpython#90633)。NeverNoReturn は型チェッカーからは同等に扱われますが、以下のように使い分けるのが慣習となっています。

  • NoReturn → 関数の戻り値型として(「この関数は返らない」)
  • Never → それ以外の場面でのボトム型として

型ナローイング

assert_never が機能するのは、型チェッカーの 型ナローイング(type narrowing) のおかげです。型チェッカーは制御フローに応じて変数の型を絞り込みます。

  1. isinstance(status, Pending)status から Pending を除外
  2. isinstance(status, Approved)status から Approved を除外
  3. isinstance(status, Rejected)status から Rejected を除外
  4. else 節では status の型は Never(空)に絞り込まれる

全てを網羅していれば else 節の statusNever 型になり、assert_never(status) は型チェックを通ります。漏れがあれば、その型が Never に代入できないとしてエラーになります。

歴史的背景

この「ボトム型を使った網羅性チェック」というイディオムは、2018年頃からmypyユーザーの間で知られていました(参考:mypy#5818)。

当時は標準ライブラリになく、各プロジェクトで自前実装する必要がありました。

# 2020年頃の自前実装
from typing import NoReturn

def assert_never(x: NoReturn) -> NoReturn:
    raise AssertionError(f"Invalid value: {x!r}")

2022年、Jelle Zijlstraによって typing.Nevertyping.assert_never がPython 3.11に追加されました(cpython#90633)。これにより、このパターンが公式にサポートされることになりました。

Python 3.10以前でも typing_extensions パッケージ(v4.1以降)から使えます。

from typing_extensions import assert_never, Never

まとめ

  • Union型やEnumの分岐には else: assert_never(x) を付ける
  • 型を追加したときの更新漏れをCIで検出できる
  • Python 3.11以降は from typing import assert_never、それ以前は from typing_extensions import assert_never

網羅性チェックを静的解析に任せて、変更に強いコードを書きましょう。

参考

1
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?