pydanticを用いて@dataclassの型を堅牢にする

## 概要  
`@dataclass`を用いると型アノテーションが強制化されますが、この型自体に強制力はありません[^1]。  
例えば、下記のように違う型を入れた場合でも動作してしまいます。
```python
from dataclasses import dataclass

@dataclass
class User:
    name: str
    age: int

def main():
    user_info = User(20, "Hoge")
    print(user_info)                # User(name=20, age='Hoge')
    print(type(user_info.name))     # <class 'int'>
    print(type(user_info.age))      # <class 'str'>

if __name__ == "__main__":
    main()
```  
なぜ強制力がないかというと、この型アノテーションは**型のヒント**でしかなく、言うのであればコメントと同様です[^2]。
仕様だとしても、せっかく定義した型が違う型に書き換わるのは解せない…    
そういう方におすすめなのが**pydantic**です！   

<details><summary>型アノテーションの補足</summary>

強制力がないならばあまり書く意味がないと思われますが、主に下記のメリットが存在します。  

- 他の開発者や未来の自分に、この変数の型は**何が入るべき**なのかを示すことが出来る
  - コードの読み解きが楽になる
- `mypy`や`Pylance`と合わせると型チェックを行ってくれて、開発しながら問題のある部分が明白になる

注意点として、Python3.5以上でなければ使用できない点です。  
なので、Python3.5以上であればバンバン使っていくことをおすすめします。  
(関数の返り値の型まで書くことが出来ます！)

</details>  


## pydanticとは  
[ドキュメント](https://pydantic-docs.helpmanual.io/)には下記のように記載されています。  

- pydanticは実行時に型ヒントを強制し、データが無効な場合はユーザーフレンドリーなエラーを提供します
- 純粋で標準的なpythonでデータがどのようにあるべきかを定義し、pydanticでそれを検証します  

と書いてありますが、この記事内では型を強制させる代物という理解で問題ありません。 

## インストール  
外部モジュールなので、下記コマンドを実行しインストールする必要があります。  
```pip install pydantic```  

## 使い方  
`from dataclasses`から`from pydantic.dataclasses`に変更するだけで使用できます！    
```python
from pydantic.dataclasses import dataclass

@dataclass
class User:
    name: str
    age: int

def main():
    user_info = User("Hoge", 20)
    print(user_info)            # User(name='Hoge', age=20)
    print(type(user_info.name)) # <class 'str'>
    print(type(user_info.age))  # <class 'int'>

if __name__ == "__main__":
    main()
```  
今まで使用してきた`@dataclass`と同じですね！  

## エラー送出機能と型変換  
ここからが`pydantic`の本領発揮です。  
下記のように定義した型と異なる型を受け取った際には、エラーを出力してくれます。    
```python
from pydantic.dataclasses import dataclass

@dataclass
class User:
    age: int

def main():
    user_info = User("Hoge")
    print(user_info)
    print(type(user_info.age))

if __name__ == "__main__":
    main()
```

実行すると下記のようなエラーが送出されます。  
```
Traceback (most recent call last):
  File "", line 17, in <module>
    main()
  File "", line 10, in main
    user_info = User("Hoge")
  File "<string>", line 4, in __init__
  File "pydantic\dataclasses.py", line 100, in pydantic.dataclasses._generate_pydantic_post_init._pydantic_post_init
    # +-------+-------+-------+
pydantic.error_wrappers.ValidationError: 1 validation error for User
age
  value is not a valid integer (type=type_error.integer)
```
このように`pydantic`を用いることで、定義した型と異なる型が与えられた時にはエラーを出力してくれます。  
……しかし、このようにエラーを出してくれるのは**キャストが出来ない**ときに限られます。  

試しにキャストが出来る文字列型の"20"を`age`に格納してみましょう。  
```python
from pydantic.dataclasses import dataclass

@dataclass
class User:
    age: int

def main():
    user_info = User("20")
    print(user_info)            # User(age=20)
    print(type(user_info.age))  # <class 'int'>

if __name__ == "__main__":
    main()
```  

このように`str`型の文字列が、定義した`int`型にキャストされて受け取っています。  
文字列だけではなく、`float`型であれ、`bool`型であれ、**キャスト出来るならキャストをしてしまいます**。  
これは`pydantic`の仕様です(バグではありません)[^3]。    

キャストで型変換出来る場合でも、キャストをせずにエラーを出して欲しい。
そんな要望にも`pydantic`は応えてくれます！[^4]  

## Strict Types
この型で定義すると、キャストでの変換を防いでエラーを出してくれます。  
種類としては`StrictStr`, `StrictBytes`, `StrictInt`, `StrictFloat`, `StrictBool`があります。  
使い方は、`from pydantic`から使用する型をimportして、型アノテーションで定義するだけです。  
```python
from pydantic.dataclasses import dataclass
from pydantic import StrictInt

@dataclass
class User:
    age: StrictInt

def main():
    # ValidationError
    # user_info = User(3.14)
    # user_info = User("3")
    # user_info = User(True)

    # OK
    user_info = User(3)

    print(user_info)            # User(name=3)
    print(type(user_info.age))  # <class 'int'>

if __name__ == "__main__":
    main()
```
キャスト出来ていたものが軒並み`Validation Error`になり、"真の`int`型"のみ受け取るように強制させることが出来ました！  
他の`StrictStr`や`StrictFloat`なども同様の動作などで省きます。[^5]  

ちなみに型を自分で作ることが出来るので、`datetime.datetime`型しか受け取らない型を作るなんてことも可能です！  

## 終わりに  
今回は、`pydantic`を使って`@dataclass`の型を堅牢にすることに絞ってまとめてみました。  
ここで使用した型は一部分で、`pydantic`は様々な型をサポートしています([参照](https://pydantic-docs.helpmanual.io/usage/types/#standard-library-types))  
また思った以上に`pydantic`は奥深く、issueやドキュメントを読んでいるだけでも面白かったのでおすすめです[^6]。  

## おまけ  

<details><summary>キャストのタイミングについて</summary>

引数で値を受け取ったと同時にキャストしているのか、と思っていたがどうやら`__post_init__`の終了後にキャストしてるっぽい。  
  
```python
from pydantic.dataclasses import dataclass


@dataclass
class User:
    age: int

    def __post_init__(self):
        print(f"{self.age=}")
        print(f"Type = {type(self.age)}")

    def OutputType(self):
        print(f"OutputType : {type(self.age)}")


def main():
    user_info = User("20")
    user_info.OutputType()


if __name__ == "__main__":
    main()
```
出力は下記の通りです。  
```
self.age='20'
Type = <class 'str'>
OutputType : <class 'int'>
```  
ちょっとびっくりした小話でした。  

</details>  

## 参考文献
https://pydantic-docs.helpmanual.io/
 
[^1]:「フィールドがPEP526で定義されたクラス変数かどうかの判定」と「フィールドが初期化限定変数かどうかの判定」では検査される([参照](https://docs.python.org/ja/3.10/library/dataclasses.html#class-variables))  
[^2]:[Type Hints](https://peps.python.org/pep-0484/#type-comments)
[^3]:[Data Conversionについて](https://pydantic-docs.helpmanual.io/usage/models/#data-conversion)
[^4]:`mypy`などの型チェッカーを導入すれば解決するが、`pydantic`だけで行いたかった
[^5]:[Strict Typesについて](https://pydantic-docs.helpmanual.io/usage/types/#strict-types)
[^6]:特に型変換の議論は読みごたえがあった([参照](https://github.com/samuelcolvin/pydantic/issues/578))