Python3.12からPEP692が導入され`**kwargs: **TypedDict`でキーワードごとの詳細な型付けやDRY化ができるようになる

## はじめに
以前、日本語での解説記事がなかった[PEP695](https://peps.python.org/pep-0695/)について記事を書き、自分のQiita記事で初めて100いいねを超えました。

https://qiita.com/junkmd/items/1aa7be17401cfebab92d

PEP695の他にもPython3.12からはPEP692が採用され、キーワード引数アノテーションに対する新文法として`**kwargs: **TypedDict`という書き方ができるようになります。

それも現時点(2023/8/22現在)で日本語記事がなく、~~それなりにバズる~~需要があるのではないかと思い、この記事を書きました。

## PEP全文

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

## このPEPが提案された経緯
これまでも関数の引数について`**kwargs: T`のようにアノテーションすれば、
- 各キーワード引数が`T`型以外を受け付けないように型制約がつく
- `kwargs`が関数内部で`dict[str, T]`として型推論される

ようになっていました。

```py
def foo(**kwargs: str):  # 各キーワード引数に`str`以外を渡すと型チェッカーエラー
    kwargs  # `dict[str, str]`に型推論される
```

しかし、`**kwargs`がキーワードごとに異なる型を持つことはよくあります。
このような場合、`**kwargs`にそれらの型の違いについてアノテーションを付けることはできませんでした。

```py
# 引数の名前も型もわかる
class hoge(
    *,
    a: int = 0,
    b: str = "",
    c: float = 0.,
    d: tuple[int, str, float] = (0, "", 0.)
) -> None:
    ...

# `**kwargs`は`hoge`に渡すことができる引数名と型に限定したいが、方法がなかった
# `Any`でアノテーションするしかないが、引数の名前も型も制約も失われてしまっている
class fuga(criteria: int, **kwargs: Any) -> None:
    hoge(**kwargs)  # 辞書をアンパックすればいいのでヘルパー関数を呼ぶのは楽
```

デフォルト値を持たないキーワード引数に対応しなければならない場合についても考えます。
`**kwargs`を使うとどの名前が必須引数であるかの情報も失われてしまいます。

```py
# `piyo(lettuce="egg")`のように必ず名前付きで呼ばなければならない
def piyo(*, lettuce: str) -> None:
    ...

# `hoge`の場合と同じく引数の名前も型も制約も失われてしまっている
def moge(**kwargs: Any) -> None:
    piyo(**kwargs)  # 辞書をアンパックすればいいのでヘルパー関数を呼ぶのは楽
```

`**kwargs`は関数が内部でヘルパー関数を呼び出す場合に、必要なコード量を減らすために使用できます。
しかし、上記のように本来渡すべき適切な引数名と型を秘匿してしまいます。

そのような事態を避けるため、より正確な`**kwargs`の型付けをサポートすることについて多くの議論があり、その結果PEP692が提案されました。

## キーワードごとに違う型付けであることを表現する
[PEP589](https://peps.python.org/pep-0589/)で提案されPython3.8から追加された[`typing.TypedDict`](https://docs.python.org/ja/3/library/typing.html#typing.TypedDict)、そして`**`を用いることで、`**kwargs`に渡されるキーワードごとに違う型が付けられていることを表現可能になります。

### `TypedDict`について
「辞書のどのキーに何の型がつけられているか」を型ヒントで表現する(ことがいくつかあるうちのひとつの)機能です。

```py
from typing import TypedDict


# 各フィールドで辞書のキーとその型を指定できる
class _Td1(TypedDict):
    foo: int
    bar: str


a: _Td1 = {"foo": 0, "bar": ""}  # OK
b: _Td1 = {"bar": "", "foo": 0}  # OK 並び順はフィールドと違っても可
c: _Td1 = {"foo": "", "bar": 0}  # NG キーごとの型が違う
d: _Td1 = {"foo": 0, "bar": "", "baz": 3.14}  # NG 余分なキーがある
e: _Td1 = {"foo": 0}  # NG フィールドで指定されたキーが不足している


# クラス定義時の引数として`total: bool`を指定できる
# デフォルトは`True`で上記`_Td1`と同じ振る舞いをする
class _Td2(TypedDict, total=False):
    foo: int
    bar: str


o: _Td2 = {"foo": 0, "bar": ""}  # OK
p: _Td2 = {"foo": 0}  # OK フィールドで指定されたキーがなくても可
q: _Td2 = {}  # OK まったく空でも可
r: _Td2 = {"foo": "", "bar": 0}  # NG キーごとの型が違う
s: _Td2 = {"foo": 0, "bar": "", "baz": 3.14}  # NG 余分なキーがある
t: _Td2 = {"baz": 3.14}  # NG フィールドの指定とは全然関係ないキーがある

```

その他の機能や仕様について、詳しくは上述の公式ドキュメントを参照して下さい。

### キーワード引数に型制約をつける

前述の関数`hoge`や`fuga`は下記のように[`**kwargs: **TypedDict`](https://peps.python.org/pep-0692/#introducing-a-new-syntax)を使って書き換えても、個別の引数名に型付けすることと同様の型制約をつけることができます。

```py
class _HogeKwargs(TypedDict, total=False):
    a: int
    b: str
    c: float
    d: tuple[int, str, float]


def hoge(**kwargs: **_HogeKwargs) -> None:
    ...
    # `TypedDict`はデフォルト値をサポートしていません。
    # ヘルパー関数にアンパックして渡さず、内部の要素を使う場合、
    # デフォルト値を補いつつ取得するには下記のような冗長なプロセスが必要となります。
    # a = kwargs.get("a", 0)
    # b = kwargs.get("b", "")
    # c = kwargs.get("c", 0.)
    # d = kwargs.get("d", (0, "", 0.))
    # なので、現実には`hoge`は従来の個別の引数に型付けした定義のままで、
    # `fuga`への型ヒントとしてだけ`_HogeKwargs`で使うことになると考えています。
    # 今回は機能の説明のため、極端な例を用いました。


# `hoge`に渡すべきキーワード引数の名前、型、制約と同じであることが表現できる
class fuga(criteria: int, **kwargs: **_HogeKwargs) -> None:
    hoge(**kwargs)
```

### 必須/任意であるキーワード引数の表現

[PEP655](https://peps.python.org/pep-0655/)で導入された[`typing.Required`](https://docs.python.org/ja/3/library/typing.html#typing.Required)と[`typing.NotRequired`](https://docs.python.org/ja/3/library/typing.html#typing.NotRequired)を使って、必須または任意であるキーワード引数についても型アノテーションで表現が可能です。

```py
def mogera(a: int, *, b: int, c: int = 0, d: str = "") -> None:
    ...
```
↓
```py
class _MogeraKwargs(TypedDict):
    b: int
    c: NotRequired[int]
    d: NotRequired[str]


def mogera(a: int, *, **kwargs: **_MogeraKwargs) -> None:
    ...
```
or
```py
class _MogeraKwargs(TypedDict, total=False):
    b: Required[int]
    c: int
    d: str


def mogera(a: int, *, **kwargs: **_MogeraKwargs) -> None:
    ...
```

### 引数名と型のDRY化

文字列を渡して、それに呼応する型が返るという関数を想定します。
```py
class Foo: ...
class Bar: ...
class Baz: ...


def find(
    kind: str,
    *,
    criteria1: int = 0,
    criteria2: str = "",
    criteria3: float = 0.,
    criteria4: tuple[int, str, float] = (0, "", 0.),
) -> Foo | Bar | Baz:
    ...
    if kind in ("Foo", "Spam", "Hoge"):
        return Foo()
    if kind in ("Bar", "Ham", "Fuga"):
        return Bar()
    if kind in ("Baz", "Bacon", "Piyo"):
        return Baz()
    raise TypeError
```

上記のような関数を呼び出したときには、下記のような型推論がされて欲しいです。

```py
x = find("Hoge")  # `Foo`に型推論される
y = find("Ham")  # `Bar`に型推論される
z = find("Baz")  # `Baz`に型推論される
```

`kind`に渡される`Literal`ごとに`overload`を設定して返す型を推論させる場合、これまでは返す型には影響しない引数もすべてWETに明示しなければ、元の関数にある引数の型制約を表現することはできませんでした。

```py
@overload
def find(
    kind: Literal["Foo", "Spam", "Hoge"],
    *,
    criteria1: int = ...,
    criteria2: str = ...,
    criteria3: float = ...,
    criteria4: tuple[int, str, float] = ...,
) -> Foo: ...
@overload
def find(
    kind: Literal["Bar", "Ham", "Fuga"],
    *,
    criteria1: int = ...,
    criteria2: str = ...,
    criteria3: float = ....,
    criteria4: tuple[int, str, float] = ...,
) -> Bar: ...
@overload
def find(
    kind: Literal["Baz", "Bacon", "Piyo"],
    *,
    criteria1: int = ...,
    criteria2: str = ...,
    criteria3: float = ...,
    criteria4: tuple[int, str, float] = ...,
) -> Baz: ...
```
`TypedDict`を使ってキーワード引数にアノテーションができるようになったことで、DRYな表現をすることができるようになりました。

```py
class _FindOptKw(TypedDict, total=False):
    criteria1: int
    criteria2: str
    criteria3: float
    criteria4: tuple[int, str, float]


@overload
def find(kind: Literal["Foo", "Spam", "Hoge"], **kwargs: **_FindOptKw) -> Foo: ...
@overload
def find(kind: Literal["Bar", "Ham", "Fuga"], **kwargs: **_FindOptKw) -> Bar: ...
@overload
def find(kind: Literal["Baz", "Bacon", "Piyo"], **kwargs: **_FindOptKw) -> Baz: ...

```

### 後方互換性

Python3.11以前でも、`TypedDict`を使って`**kwargs`へアノテーションすることができます。
しかし`**kwargs: **T`という文法は許されていないので、[`typing.Unpack`](https://docs.python.org/ja/3/library/typing.html#typing.Unpack)を使わなければなりません。

```py
import sys
if sys.version_info >= (3, 11):
    from typing import Unpack
else:
    # Python3.11以前は標準ライブラリに`Unpack`がないので
    # `typing_extensions`からimportする
    from typing_extensions import Unpack


def hoge(**kwargs: Unpack[_HogeKwargs]) -> None:
    fuga(**kwargs)
```

:::note info
これは、Python3.11より前では[`TypeVarTuple`](https://docs.python.org/ja/3/library/typing.html#typing.TypeVarTuple)を使っても`*args: *Ts`とは書けないため、`*args: Unpack[Ts]`と書く([PEP646](https://peps.python.org/pep-0646/))[^1]ということに相似しています。

:::

:::note warn
PEP692原文は`Unpack`を使うユースケースが先に紹介され、`**`を型ヒントシンボルに対して使えるようになることへの言及は[最後のセクション](https://peps.python.org/pep-0692/#introducing-a-new-syntax)に限られています。

しかしPEP646など静的型付けに関するPEPの傾向を見ると、新しいPythonのバージョンでは型アノテーション表現のために新文法を積極的に導入する[^2]ことがトレンドのようです。

そして、Pythonコア開発者は[PEP585](https://peps.python.org/pep-0585/)導入後の対応に見られるように[(置き換えることに問題がないなら)古い記法を使わず新しい記法を使うように推奨](https://github.com/python/typing/issues/1230#issuecomment-1202268178)しています。

なので、この記事ではPEP692原文のユースケースとは違ってPython3.12の新文法の方をメインにおいています。

:::



### その他
PEP692原文には他にも[位置引数との衝突を避ける方法](https://peps.python.org/pep-0692/#keyword-collisions)、[型付けされていない`kwargs`との共存](https://peps.python.org/pep-0692/#source-contains-untyped-kwargs)、[`TypedDict`の派生型をさらに派生させた際の振る舞い](https://peps.python.org/pep-0692/#passing-kwargs-inside-a-function-to-another-function)などが記載されています。

## まとめ
私は以前から[`TypedDict`](https://docs.python.org/ja/3/library/typing.html#typing.TypedDict)の存在を知ってはいました。
しかし、

- 「キーごとに型が違う辞書」を型付けして使うぐらいなら、代わりにドットアクセスが可能でユーティリティが充実している[`dataclasses.dataclass`](https://docs.python.org/ja/3/library/dataclasses.html)や[`typing.NamedTuple`](https://docs.python.org/ja/3/library/typing.html#typing.NamedTuple)のようなクラスを使うべき
- 「キーに抜けがあるかもしれない辞書」を型付けして使うぐらいなら、代わりに構造的部分型を表現できる[`typing.Protocol`](https://docs.python.org/ja/3/library/typing.html#typing.Protocol)を使うべき

という考えでした。

しかし、今回導入されたPEP692は、`**kwargs`を`TypedDict`を使ってアノテーションすることで
- 辞書としての振る舞いを表現する方法が使える
- 辞書のキーが必須なのか任意なのかということで、各キーワード引数が必須なのか任意なのかということを表現できる
- キーはPythonの関数に渡す引数名となるので、「`TypedDict`のフィールド名はPythonの変数名として適切でなければいけない」という制約[^3]がバグの早期発見につながる

ので、初めてその存在意義を感じることができました。

`**kwargs`についてキーワードごとに型が違うことの表現ができたのだから、`*args`について位置ごとに型が違うことの表現もできるのではないかと、私は期待しています。

型アノテーションで表現できることが増えていくことは、ロバストなコードを書くことに役立っていくことでしょう。


[^1]: `TypeVarTuple`のユースケースについては下記に素晴らしい解説記事があります。
https://qiita.com/kissy24/items/79fd3691bb77ec5ea2cb

[^2]: `*T`や`**T`などの表現が文法的に許されていない後方バージョンのために、`Unpack`などの特別なシンボルも用意することもトレンドのようです。

[^3]: [PEP589にあるように](https://peps.python.org/pep-0589/#alternative-syntax)`X = TypedDict('X', {'y': str, 'x-z': int})`とすれば、Pythonの変数名として使用できない文字列も辞書のキーへの制約として設定することができます。