【Python】【上級者向け】型絞り込み(type-narrowing)を使ったガード節は`isinstance(a, B)`ではできるけど`type(a) is B`ではできない

## きっかけ
チームメンバーから「VSCode上でPythonを書いていて、型判定をコード上でしているのにPylanceが型絞り込みからのガード節をしてくれない」という相談を受けたときに行った回答や例をまとめておこうと思ったため、記事を書きました。

## 型絞り込みについて

一般的なPythonの静的型チェッカー(`mypy`や`pyright`など)は、プログラムのコードフローの中でより正確な式の型を決定するtype-narrowing(型の絞り込み)を行なっています。  
- これは`typing.TypeGuard`に関するPEPであるPEP647にも記載があります。

https://peps.python.org/pep-0647/#motivation

ざっくばらんに言うと、「直和型(`typing.Union`)の変数に対して型判定の分岐を書いたら、その分岐に入る/入らない場合で変数に対して異なる型推論をしてくれるよ」というものです。

## 関数の要件
**「整数インスタンスまたは文字列インスタンスが渡される。0以上の整数または数字であれば`True`、それ以外なら`False`を返す」** ことが要件である関数を実装します。

引数と返り値はこのようになります。
```py
def foo(a: int | str) -> bool:  # 3.10以降で有効な書き方です
	...
```

この関数に型チェッカーがエラーを出さないようにして適切な実装をするにはどうすればよいでしょうか。

## 型絞り込みできないケース

Pythonで型を判定する手段の一つとして、`type(a) is B`で判定する方法があります。

```py
def foo(a: int | str) -> bool:
	if type(a) is int:
		return a >= 0
	return a.isnumeric()
```

これで実装してみます。
すると、VSCode上で`mypy`や`pylance`による型チェッカーがエラーを表示します。  
※ ツールチップがコードの上にかぶらないように、大きな改行幅を取っています。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/364180/efb2a50f-dbd0-82ac-4c70-8fb4fec529be.png)

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/364180/be8dca1a-508f-1d52-5fa5-618294087cc4.png)

ここで、型判定を行った分岐後の引数`a`にマウスカーソルを当てると、型情報は`int`となっています。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/364180/3ca57f00-8dba-647c-fb4d-66e7bc8fcbae.png)

**`int`である分岐に入らなかったなら`str`に絞り込めていそうなのに、なぜか型チェッカーは変数`a`が`str | int`の可能性がまだあると言い張っています。**

## 型絞り込みできるケース

もう一つ、Pythonには`isinstance(a, B)`という型判定用の組み込み関数があります。こちらを使ってみましょう。

```py
def foo(a: int | str) -> bool:
	if isinstance(a, int):
		return a >= 0
	return a.isnumeric()
```

ご覧のように、エラーは出ていません。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/364180/c60b4ef4-db84-e040-c86d-f16836e8fd3e.png)

マウスカーソルを当てても、型がちゃんと絞り込めていることを確認できます。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/364180/f5fa9532-f32b-a388-7d3b-a19ef614ec08.png)

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/364180/695b1283-4fd0-f04b-b0a8-a6f7a981374d.png)


## なぜこのようになるのか
結論をいうと
- Pythonの型アノテーションは、指定されたクラスだけでなく、その派生クラスであれば受け入れる
- `type(a) is B`: `B`だけで、`B`の派生型は考慮されていない
- `isinstance(a, B)`: `B`だけでなく、`B`の派生型も考慮されている

ことが理由です。

つまり、

- **`type(a) is B`が`False`を返しても`isinstance(a, B)`が`True`を返す場合であれば、`func(arg: B)`に`a`を渡しても型チェッカーがエラーになることはありません。**  
- **`func(arg: B | C)`関数内の分岐で`type(arg) is B`を分岐に使うと`B`の派生型は分岐に入らないため、型が絞り込めず分岐を抜けても`arg`の型は`B | C`と推測され、`C`にあって`B`にないメソッドを使おうとすると型チェッカーがエラーになります。**

## ランタイムでエラーにならない/なる例

`int`の派生型を実際に渡そうとした場合を考えてみます。
例えば[`enum.IntEnum`](https://docs.python.org/ja/3/library/enum.html#enum.IntEnum)は`int`のサブクラスです。
これを関数に渡したとき、

- `isinstance(a, int)`であれば、関数の2行目にある分岐に入って3行目にある演算結果を`return`します。
- `type(a) is int`であれば、関数の2行目にある分岐に入らず4行目のメソッド参照で`AttributeError`がraiseされます。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/364180/ad5723fd-47fb-7c2f-7d43-2d763fe36db5.png)


## 終わりに
型アノテーションは指定したクラスだけではなく、その派生型も意識するべきです。

逆を言えば、**基底クラスで使えるメソッドが派生クラスでは使えなくなる、SOLID原則の1つであるリスコフの置換原則に違反するようなクラスを作ってはいけない**ということです。

```py
# 下記のクラスはものすごい極端な例です
# ここまででなくても、派生クラスの同じメソッドが違う型を返すようなケースは多々あることでしょう
# ともかく、これは`int`のサブクラスなので`isinstance(..., int)`をすり抜けてしまいます
# このような型が紛れ込んでしまうとバグ原因の発見は難しくなります
class CannotAddInt(int):
    """+演算子がサポートされておらず使おうとするとエラーになる`int`のサブクラス"""
    def __add__(self, other):
        return NotImplemented

    def __radd__(self, other):
        return NotImplemented
```

「プロジェクトで引き算や割り算や掛け算はできても足し算できない整数型を導入したいけど、`a: int`となっている引数を受け取って足し算をする関数に渡るかもしれない」というような状況になったら、そのようになってしまった理由をブレークダウンして全体の設計を考え直したほうがいいでしょう。