PythonのTypeGuardについて調べてみた（PEP647）

今までなんとなく雰囲気で「`isinstance`や`is None`とかでTypeGuard（型ガード）効くよねー」くらいの浅い知識でPythonのTypeGuardを使っていたので、該当のPEPを読んだりしてその辺りしっかり調べてみました。

# 本記事で使うPythonバージョンとライブラリなど

個人で作っているPythonライブラリでも役立てるという目的もあるため、そのライブラリでサポートしているPythonバージョン的に本記事執筆時点でまだEoLを迎えていないPython3.7などの古めのPythonバージョンも意識した形で進めます。

そのためtyping_extensionsなどのバックポートのパッケージの利用や古い`List`や`Dict`などでの古い形での型アノテーション等も加味しています。

また、型チェックに関してはPylance（Pyright）とmypyをベースとします。どちらかのライブラリでしかサポートされていない・・・といった類のものは本記事ではスキップしていきます。

記事中のスクショなどで表示しているエディタ上の型の表示はVS Code上のPylanceのものを使用しています。Pylanceに関しては古めですが以前記事にしているため必要な場合はそちらをご参照ください。

https://qiita.com/simonritchie/items/33ca57cdb5cb2a12ae16

# そもそもTypeGuardとは

TypeScriptとかに慣れている方には不要な感じだとは思いますが、まず最初にTypeGuardとは何なのか、どんな時にTypeGuardが便利なのかについて軽く触れておきます。

例として以下のようなサンプルの関数を考えます。

```py
from typing import Optional


def sample_func(message: Optional[str]) -> None:
    message = message.replace("Apple", "Orange")
```

message引数に関しては`Optional[str]`と型を定義しているのでNoneを取り得る形になっています。そのためnull安全的にmessage引数に対して上記コードのようにreplaceメソッドなどを直接呼び出そうとするとmypyやPylance（Pyright）などの型チェックでエラーになります。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/228778/96ca4f64-8010-dce1-07db-6a96dcae63f6.png)

一方で`if message is not None`といったような分岐条件を設けてその条件を満たした場合の時だけmessageメソッドなどを利用する・・・とした場合には、messageはNoneではないことが分かっているのでこちらはエラーにはなりません。

```py
from typing import Optional


def sample_func(message: Optional[str]) -> None:
    if message is not None:
        message = message.replace("Apple", "Orange")
```

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/228778/1fdd4995-1549-863d-df8b-3b8519ebc320.png)

このように「型が〇〇だと分かっている」といった場合に型チェックでもその型として変数などを参照できるように型を絞り込む機能をTypeGuardと呼びます（厳密には色々違うかもしれませんが大雑把な定義としてご容赦ください）。

また、対象の型を狭めるという感じでType narrowingなどと呼ばれたりもします。

型チェック面でのメリットの他にもVS Code（とPylanceなどを使った場合）などのエディタ上での入力補完の候補も絞り込んだ後の型が反映されるため参照するメソッドや属性などでミスをしにくくなるというメリットもあります。

# 最初から使えるTypeGuardについて

自前で定義などしなくとも最初からPython上で利用できるTypeGuardについて全てではありませんがある程度触れていきます。

## is None

対象の値がOptionalな場合に`is None`によるif文などを挟むことでそのif文の中ではNoneの値として扱われます。

例えば以下のコードのif文の中でmessage引数にアクセスするとNoneに絡んだ属性などのみ表示され、文字列関係の属性やメソッドなどは表示されないことを確認できます。

```py
from typing import Optional


def sample_func(message: Optional[str]) -> None:
    if message is None:
        return
```

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/228778/fec8751b-619e-1273-85ae-9cf3c15c35be.png)

関数などの中でこのようなif文を設けてreturnを設定したり、raiseでエラーで止めるようにしたり、ループ中でcontinueなどを指定した場合やif文でelseなどを使用した場合でもTypeGuardによる型の絞りこみは効きます。つまりreturnなどの後の記述では該当の値はNoneではない・・・といった判定になります。

試しにreturnの後の部分でmessage引数の値にマウスオーバーしてみると、型でOptional関係の記述が消えてstrと表示されていることが分かります。

```py
from typing import Optional


def sample_func(message: Optional[str]) -> None:
    if message is None:
        return
    message
```

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/228778/b09b14ec-11eb-c6c7-9527-07a204623bc5.png)

## is not None

`is None`の記述ではnotを挟んだ否定条件でもTypeGuardによる型の絞り込みが行われます。

たとえば以下のように`is not None`のif文の中でmessage引数の値にマウスオーバーしてみると型でOptional関係の記述が消えてstrと表示されることが変わります。

```py
from typing import Optional


def sample_func(message: Optional[str]) -> None:
    if message is not None:
        message
        return
```

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/228778/6f91d266-dd74-e9a0-c407-2e292264fb79.png)

## isinstance

isinstanceでも同様に型の絞り込みが行えます。

たとえば以下のようにintとstrによるUnionの型アノテーションがされている値に対して、isinstanceでintかどうか判定すればそのif文内ではintの値として扱われます。strとは表示されなくなります。

```py
from typing import Union


def sample_func(value: Union[int, str]) -> None:
    if isinstance(value, int):
        value
```

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/228778/0dcf8cfc-b49d-7b61-bcbb-014e3ca7d44d.png)

notを付けた場合やreturn、continue、elseなどを併用した場合の挙動も`is None`と同じ形になります。たとえば以下のようにelseを使えばその中では引数の値はintではなくstrと判定されます。

```py
from typing import Union


def sample_func(value: Union[int, str]) -> None:
    if isinstance(value, int):
        ...
    else:
        value
```

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/228778/ff006375-99a6-87e4-7373-4ee9bda8dc8e.png)

## Literalの値

enumのように文字列や整数等々の型で特定の値のみ受け付けたい場合のLiteralの指定でもif文などで絞り込みが効きます。

以下の例では引数の型アノテーションでは`"cat"`もしくは`"dog"`という文字列を受け付ける型指定になっていますが、`"cat"`かどうかといった判定を行っているif文内では値の型が`Literal["cat"]`といったように`"cat"`の値のみに絞り込まれていることが確認できます。

```py
from typing import Literal


def sample_func(animal: Literal["cat", "dog"]) -> None:
    if animal == "cat":
        animal
```

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/228778/8cd87b6f-3d4e-11a8-36f8-318674e1f3a9.png)

# ユーザーが定義するTypeGuardについて

ビルトインのTypeGuardでも多くのケースがカバーできるのですが、一方で自前の真偽値を返す関数などでも型の絞り込みが有効になってほしいケースが存在します。

たとえば以下のようにリストの中身が全て文字列かどうかの真偽値を取得するis_str_listという関数を用意したとします。

```py
def is_str_list(list_value: list) -> bool:
    return all([isinstance(value, str) for value in list_value])
```

この関数を使ってisinstanceのようにif文を挟んでみても特に型が`list[str]`になったりはしません。`list[Unknown]`のままです。

```py
def is_str_list(list_value: list) -> bool:
    return all([isinstance(value, str) for value in list_value])


def sample_func(list_value: list) -> None:
    if is_str_list(list_value=list_value):
        list_value
```

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/228778/792128c6-7f89-4d1d-9405-9e743e8730a3.png)

こういったケースでもisinstanceなどのように型の絞り込みが行われるようにするための書き方などについて触れていきます。

## 利用できるPythonバージョン

ユーザーが定義できるTypeGuardに関してはPython3.10以降のバージョンからビルトインで使用できます。

3.10以降のPythonバージョンであれば以下のようにtypingパッケージに必要な型などが入っているのでそちらを利用します。

```py
from typing import TypeGuard
```

もし3.10よりも前のPythonバージョンで利用したい場合にはバックポート用のtyping_extensionsパッケージをインストールすることで利用可能になります。mypyとかをインストールしていれば一緒にインストールされていると思われます（ただし古い場合にはアップデートが必要になるかもしれません）。

```
$ pip install typing-extensions
```

インストール後はtypingパッケージの代わりにtyping_extensionsパッケージからTypeGuardをimportすることでバックポートする形で古いPython環境でも使用することができます。

```py
from typing_extensions import TypeGuard
```

本記事では前述の通り古いPythonバージョンも考慮した形で進めるため、こちらのtyping_extensionsパッケージの方を使用していきます。

## 書き方

ユーザー定義のTypeGuardを書くには以下のような条件が必要です。

まず、対象の関数の返却値の型アノテーションは`TypeGuard[条件を満たす場合の型]`といった記述にする必要があります。

例えば文字列のリストかどうかの判定用の関数を設けてTypeGuardを設定したい場合には関数の返却値の型アノテーションは`TypeGuard[List[str]]`といったように設定します。

また、対象の関数に関して返却値はboolの型になるようにする必要があります。関数内の各分岐でboolが返るようにしないといけません。返却値の型アノテーションの記述は先ほど触れたようにboolではなく`TypeGuard[...]`という記述になりますが、返却値はboolとなります。

先頭の引数で指定された値に対して返却値の型アノテーションで指定された型でのTypeGuardが反映される・・・という形となるため、基本的に対象のTypeGuard用の関数は1つの引数のみ受け取る形での実装となります（ジェネリックでの例外などはあります）。もしメソッドなどの場合で最初の引数がselfやclsなどになっている場合には2番目の引数の値が参照されます。

前述の文字列のリストかどうかのチェック用のTypeGuardの関数で例を上げると以下のような記述になります。

```py
from typing import List

from typing_extensions import TypeGuard


def is_str_list(list_value: list) -> TypeGuard[List[str]]:
    return all([isinstance(value, str) for value in list_value])
```

実際に以下のように別の関数内で型の絞りこみを行ってみても`list[Unknown]`ではなく`List[str]`としてちゃんと認識してくれていることを確認できます。

```py
...
def sample_func(list_value: list) -> None:
    if is_str_list(list_value=list_value):
        list_value
```

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/228778/9a5c2226-93c4-47bd-6d54-5f3642fa38af.png)

## ジェネリックを使った複数の引数を設定する汎用的なTypeGuardの設定

前節でTypeGuard用の関数の引数は基本的に1つ（メソッドなどでselfなどを含めると2つ）と触れましたが、例外的にジェネリックで型を別の引数に渡す形で複数の引数を指定することもできます。

例えば「文字列のリストなのか」「整数のリストなのか」などを個別に定義せずに2つ目の引数にジェネリックとして指定しつつ、返却値のTypeGuardの型アノテーションにもそのジェネリックの型指定を設定する・・・といった具合です。isinstanceみたいな感じになります。

※Pythonのジェネリックに関しては以前記事を書いたので必要な場合はそちらもご参照ください。

https://qiita.com/simonritchie/items/04a9dbed28c2b423d550

コードで書いてみると以下のようになります。

```py
from typing import List, TypeVar, Type

from typing_extensions import TypeGuard

ListValue = TypeVar('ListValue')


def is_list(list_value: list, value_type: Type[ListValue]) -> TypeGuard[List[ListValue]]:
    return all([isinstance(value, value_type) for value in list_value])
```

試しに別の関数で第二引数にstrを指定して型の絞り込みをしてみます。

```py
def sample_func(list_value: list) -> None:
    if is_list(list_value=list_value, value_type=str):
        list_value
```

ちゃんと`List[str]`として認識してくれていることが確認できます。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/228778/631ba08c-05e5-cb3f-427c-e7e04acb53d8.png)

第二引数に今度はintを指定してみます。こちらもちゃんと`List[int]`として型の絞り込みが実行されることが確認できます。

```py
def sample_func(list_value: list) -> None:
    if is_list(list_value=list_value, value_type=int):
        list_value
```

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/228778/800c7232-afff-6cd5-8b90-11f0f25bd915.png)

# 参考文献・参考サイト

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

https://mypy.readthedocs.io/en/latest/type_narrowing.html

https://qiita.com/simonritchie/items/33ca57cdb5cb2a12ae16

https://qiita.com/simonritchie/items/04a9dbed28c2b423d550