[Python3.8～]ビルトインのTypedDictについて詳しく調べてみた（PEP589）

最近お仕事とプライベートで使い始めたPythonビルトインのTypedDictを使い始めたので詳しく調べて記事にしておきます（[PEP 589 -- TypedDict: Type Hints for Dictionaries with a Fixed Set of Keys](https://www.python.org/dev/peps/pep-0589/)）。

# TypedDictって何？

通常の辞書よりもより型の定義が細かく、厳密で固定的な辞書です。

今までも例えば「キーに文字列、値に整数と文字列を含む辞書」の型アノテーションをしたい場合には以下のように`Dict[str, Union[int, str]]`といった型アノテーションで対応ができていました（もしくは3.10などの新しいPythonバージョンでは`dict[str, int|str]`など）。

```py
from typing import Dict, Union

any_dict: Dict[str, Union[int, str]] = {
    'name': 'John',
    'age': 21,
}
```

しかしこれでは各キーがintとstr両方を取れてしまう、という問題があります。例えば上記の例でいえばnameに整数を設定したりageに文字列を設定しても型のチェックに引っかかりません。

```py
any_dict: Dict[str, Union[int, str]] = {
    'name': 2631,
    'age': '21歳',
}
```

TypedDictではこういったケースを避け、より堅牢な辞書の型チェックを実現します。以下のうよな形で定義します（詳しくは後々の節で触れます）。

```py
from typing import TypedDict


class Person(TypedDict):
    name: str
    age: int


person: Person = {
    'name': 'John',
    'age': 21,
}
```

この型アノテーションにより、nameのキーには文字列・ageのキーには整数しか設定できなくなります（型チェックで引っかかるようになります）。

TypedDictを使うことでより堅牢な辞書定義ができます。一方で柔軟性は減るので、例えばキー名が動的に決定したりキーの構成が変動したりするような辞書には向いていません。

# 必要なPythonバージョン

TypedDictがビルトインで入ったのはPython3.8からとなります。

# 古いPythonバージョンで使いたい場合には

3.8よりも前のPythonバージョン、例えばPython3.6とか3.7でTypedDictを使いたい場合、もしくは外部公開のライブラリで古いPythonバージョンもサポートする場合にはバックポート用のtyping-extensionsパッケージがPyPIに登録されているのでそちらをpipでインストールします。mypyとかインストールした場合には恐らく一緒にインストールされています。

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

importの記述も以下のようにtypingではなくtyping_extensionsとする必要があります。

```py
from typing_extensions import TypedDict
```

# 記事中で使うもの

以下のものを使っていきます。

- VS Code
- Python 3.8.5
- 型チェックと補完用のPylanceの拡張機能（参考 : [[Python]PylanceのVS Code拡張機能をさっそく使ってみた。](https://qiita.com/simonritchie/items/33ca57cdb5cb2a12ae16)）

# 基本の書き方と挙動

importはtypingパッケージもしくはtyping_extensionsパッケージから行います。

```py
from typing import TypedDict
```

型の定義自体はクラスで行います。TypedDictを継承する形で定義します。

```py
class Person(TypedDict):
    ...
```

辞書で必要な各キーに対する型アノテーションは属性として1つ1つ定義します。

```py
class Person(TypedDict):
    name: str
    age: int
```

属性名はそのままキー名として使われます。つまり属性名として定義できない整数などはTypedDictのキーとして使えません。

また、TypedDictのクラスの属性に初期値などを設定することもできません。型アノテーションの定義のみ行えます。初期値などを設定しようとすると怒られます。

```py
class Person(TypedDict):
    name: str = 'John'
    age: int
```

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/228778/e144feb2-3f7f-8a60-0bab-96d6b3637c2a.png)

値に設定する場合には型アノテーションに`Dict`や`dict`の代わりに定義したクラスを指定します。値の設定は通常の辞書と変わりありません。

```py
person: Person = {
    'name': 'John',
    'age': 21,
}
```

注意点として、辞書を生成するタイミングでTypedDictで必要な各キーを設定する必要があります。途中でキーを追加して最終的にTypedDictで必要なキーが揃う・・・といった書き方はできません。

例えば以下のような記述だと型チェックに引っかかります。

```py
person: Person = {}
person['name'] = 'John'
person['age'] = 21
```

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/228778/f70a5111-6e5e-688c-ac4d-7dbe977eafe7.png)

型アノテーションを先にやっておいて、辞書の値は後で設定するという書き方は他と同様に問題なく行えます。

```py
person: Person
person = {
    'name': 'John',
    'age': 21,
}
```

# 補完が充実する

TypedDictを使うことでキーの補完が充実します。例えばVS Code + Pylance環境では辞書の後で`[]`の括弧を入力した際に選択できるキーが入力候補に表示されることが確認できます（スクショの一番上の補完は別途使っているTabNineのものでTypedDict関係ないのでスルーしてください）。

```py
person[]
```

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/228778/92a0cae1-54cb-e403-ab28-20b0af6c5dc1.png)

# 値の更新時にも型のチェックが実行される

辞書の既存の値に対する更新をする際やキーを追加する場合などにも型チェックが走ります。

例えば以下のように整数のキーに対して文字列を設定しようとすると怒られます。

```py
person['age'] = '21歳'
```

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/228778/fe42df36-38f1-1933-899a-ed39416ea7dd.png)

また、TypedDictで定義されていないキーを設定しようとしても怒られます。

```py
person['address'] = 'Britain'
```

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/228778/c0ca57df-d4e6-7047-7d28-4ccdd47be628.png)

updateなどの辞書のメソッドを使う場合にも各キーの型が一致していないと怒られます。

```py
person.update({'name': 'John', 'age': '21歳'})
```

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/228778/d7235c00-8f91-2aa5-8bd4-1b961d197127.png)

また、updateメソッドでは一部だけのキーを持った辞書での更新も弾かれるようです。上記の例で言えばPersonクラスは別のPersonクラスの辞書でしか更新ができない（キーが欠落していると弾かれる）形となります。

```py
person.update({'age': 20})
```

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/228778/ba74076c-72f1-13fa-58b5-7b99dff0d109.png)

以下のようにキーが揃っていて型も一致していればupdateなどのメソッドを通すことができます。

```py
person.update({'name': 'John', 'age': 20})
```

# 各キーの設定を必須にするかどうかの設定

クラス定義の際にtotal引数にFalseを指定すると、各キーが必須ではなくなります（Optional的になります）。

```py
class Person(TypedDict, total=False):
    name: str
    age: int
```

デフォルトではtotalはTrue（各キーの指定が必須の状態）になります。
Falseを指定した場合には、以下のように個別に値を設定したり、特定のキーが欠落していても型チェックには引っかかりません。

```py
person: Person = {
    'name': 'John',
}
```

定義されていないキーの設定に対するチェックや型チェックなどが実行される点は変わりません。

```py
person['address'] = 'Britain'
```

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/228778/ab0dc7bc-7a2e-b6a1-5021-66a84611cbe0.png)

# 継承や多重継承は使える

継承や多重継承は通常のクラスと同様に行えます。

例えば以下のように、Point2DというクラスはXクラスを継承する形でxとyという必須のキーを定義することができます。

```py
class X(TypedDict):
    x: int


class Point2D(X):
    y: int


point: Point2D = {
    'x': 10,
    'y': 20,
}
```

また、多重継承させる形で定義を作ることもできます。この際には親のクラスは全てTypedDictを継承している必要があります。

以下のPoint2DクラスではXとYクラスを多重継承させる形で定義しています。

```py
class X(TypedDict):
    x: int


class Y(TypedDict):
    y: int


class Point2D(X, Y):
    ...


point: Point2D = {
    'x': 10,
    'y': 20,
}
```

多重継承する際にはTypedDictを継承したクラスと継承していないクラスを混ぜることはできません。

以下のようにXはTypedDict、Yはそうではないクラスとするとエラーになります。

```py
class X(TypedDict):
    x: int


class Y:
    y: int


class Point2D(X, Y):
    ...
```

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/228778/d3fccda6-2e74-eaf6-8978-17ac2bc629ff.png)

各クラスで同名の属性が存在し、且つ型の定義がそれぞれで異なる（競合している）場合はPEP文章上は許可されていません。ただしこの辺りは型チェックのライブラリ次第で挙動が異なると思われ、今回使うPylanceの場合あとで設定しているクラスの方で上書きされているようです。

例えば以下のようにXというクラスとXYというクラスがあり、Xクラスではx属性を整数、XYクラスではx属性を文字列と定義している状態で、X, XYの順番で継承させた場合x属性に文字列を設定していればPylanceのチェックを通りました。

```py
class X(TypedDict):
    x: int


class XY(TypedDict):
    x: str
    y: int


class Point2D(X, XY):
    ...


point: Point2D = {
    'x': '10',
    'y': 20,
}
```

ただし紛らわしいですしPEP文書でも許可されていないようなのでこういった型の不一致の記述は避ける方が良さそうではあります。

# total引数と継承を使って、必須のキーと必須ではないキーを設定できる

前節までで触れたtotal引数設定と継承を組み合わせて使うことで、必須のキーと必須ではないキーの定義をそれぞれ行うことができます。

以下の例ではOptionalKeysクラスのx属性は必須ではない値、RequiredKeysクラスのyとzの属性は必須としています。辞書を作るときにxは省略可能な辞書となります。

```py
class OptionalKeys(TypedDict, total=False):
    x: int


class RequiredKeys(TypedDict):
    y: int
    z: int


class Point3D(OptionalKeys, RequiredKeys):
    ...


point: Point3D = {
    'y': 20,
    'z': 30,
}
```

# TypedDictクラスを使って辞書のインスタンスの生成もできる

TypedDictを継承したクラスのインスタンス生成は通常の辞書の初期化だけでなく、そのクラス自体のコンストラクタを使うこともできます。その場合はキーワード引数に各属性を指定します。補完的にもVS Code上でも各引数と型は表示されます。

```py
class Point2D(X, Y):
    ...


point: Point2D = Point2D(x=10, y=20)
```

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/228778/5254d95d-8db3-e915-00ed-5a20fbd95ce7.png)

# isinstanceのチェックなどはできない

`Dict[str, int]`的な型アノテーションがisinstanceの第二引数で使えないのと同様、TypedDictのものもisinstanceで指定することはできません。ランタイムでのエラーとなります。

```py
>>> print(isinstance(point, Point3D))

TypeError: TypedDict does not support instance and class checks
```

というのも、ランタイム上ではTypedDictで型アノテーションしたからといって、値は辞書（dict）のままです。辞書のサブクラスなどにはなっていないためチェックはできない形となります。

```py
>>> print(type(point))

<class 'dict'>
```

# deleteの挙動

一度作成したTypedDictの辞書は、total引数にFalseが設定されていないとdelキーワードなどでキーの削除が効きません。

例えば以下の例ではxのキーはdelキーワードで削除が効くものの、yやzのキーは削除しようとすると怒られます。

```py
class OptionalKeys(TypedDict, total=False):
    x: int


class RequiredKeys(TypedDict):
    y: int
    z: int


class Point3D(OptionalKeys, RequiredKeys):
    ...


point: Point3D = {
    'x': 10,
    'y': 20,
    'z': 30,
}
```

エラーにならないケース :

```py
del point['x']
```

エラーになるケース :

```py
del point['y']
```

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/228778/681b862c-1800-d7dd-705a-330ba087637e.png)

# 参考文献

- [PEP 589 -- TypedDict: Type Hints for Dictionaries with a Fixed Set of Keys](https://www.python.org/dev/peps/pep-0589/)