🐍

Pythonの型ヒントと共に進化するコード(#4: TypedDict)

に公開
これまでの連載記事


前日の記事では出発点となる「脆い」スクリプトの全体感を確認しました。

本日から型ヒントを使ってこのスクリプトを進化させていきます。

今回の課題:辞書という名の「何でも箱」

もう一度、前回の記事で見たコードの一部を振り返ってみます。

response.json() で API から取得したデータを辞書として受け取り、各キーにアクセスしている部分です。

address_fetcher.py
# ...

# API からのレスポンスを辞書に変換
address_data = response.json()

# 辞書から住所情報を取り出す
prefecture = address_data["prefecture"]
city = address_data["city"]
town = address_data["town"]
# ...

requests ライブラリの .json() メソッドは JSON レスポンスを Python のオブジェクトに変換してくれます。この API の場合は辞書(dict)として返ってきます。
この変換は開発者からするととても便利ですが、型チェッカーから見ると話は別です。

.json() の戻り値の型は Any です。つまり型チェッカーからすると「何が返ってくるかわからない」状態です。

# 型チェッカーには以下のように見えている
address_data: Any = response.json()

Any 型の変数はどんな操作も許容されてしまうため、存在しないキーにアクセスしても型チェッカーは警告してくれません。

この「何でも箱」状態が以下のような問題を引き起こします。

1. キーの打ち間違いに気づけない

もし address_data["prefectur"] のようにキーをタイプミスしても型チェッカーは何も警告してくれません。エラーが発覚するのはプログラムを実行してその行が評価された瞬間です。

2. API の仕様変更に弱い

ある日、外部 API の仕様が変更され "town" キーが "street" に変わったとします。この変更に気づかず古いコードを実行すると、プロダクション環境で KeyError が発生して初めて問題が発覚するということになりかねません。

3. コードが読みにくい

address_data という変数を見ただけではこの辞書がどんなキーを持っているのか全く分かりません。中身を理解するには API のドキュメントを調べるか print() 等で出力して確認する必要があります。

これらの問題はコードが小さい間は管理できるかもしれませんが、アプリケーションが成長するにつれて深刻なバグの原因となっていきます。

処方箋:辞書にスキーマを与える TypedDict

この「何でも箱」問題を解決するための処方箋が typing モジュールに含まれる TypedDict です。

TypedDict は辞書に対してスキーマ、つまりどのようなキーを持ちそれぞれのキーの値がどの型であるべきかという構造を定義するための仕組みです。

まずは簡単な例を見てみましょう。

from typing import TypedDict

# TypedDict を継承した 'AddressInfo' というクラスを作り、スキーマを定義する
class AddressInfo(TypedDict):
    prefecture: str
    city: str
    town: str

# 使用例
def print_address(address: AddressInfo):
    # 引数の address は 'AddressInfo' のスキーマに従う辞書であることを期待する
    print(f"都道府県: {address['prefecture']}")
    print(f"市区町村: {address['city']}")

# この辞書は AddressInfo のスキーマを満たしている
addr: AddressInfo = {"prefecture": "東京都", "city": "千代田区", "town": "千代田"}
print_address(addr)

TypedDict で型を定義しておくと、型チェッカーが辞書のキーを認識してくれるようになります。

例えば、存在しないキーにアクセスしようとすると...

print(address['street'])
# 👉 型チェッカーが「'AddressInfo' に 'street' というキーは存在しない」とエラーを出してくれる

もしキーをタイプミスすると...

print(address['prefectur'])
# 👉 型チェッカーが「'prefectur' というキーは存在しない」と指摘してくれる

実際に mypy を実行すると、以下のようなエラーが出力されます。

$ mypy example.py
example.py:14: error: TypedDict "AddressInfo" has no key "prefectur"  [typeddict-item]
Found 1 error in 1 file (checked 1 source file)

このエラーメッセージはどのファイルの何行目に問題があるのか、そしてどのキーが存在しないのかを明確に教えてくれます。実行する前にミスを発見できるのです。

TypedDict を挟んでおくと、辞書は単なる「何でも箱」から構造が定義された安全なデータオブジェクトに変わります。

コードの進化:スクリプトへの適用

それでは、この TypedDictaddress_fetcher.py に適用してみましょう。

変更前のコード

(変更前)
# ...
address_data = response.json()
# ...

TypedDict の適用

まず API レスポンスの構造に合わせて AddressDict を定義します。
そして .json() メソッドの戻り値にこの型注釈を追加します。

(変更後)
import requests
import json
from typing import TypedDict # TypedDictをインポート

# 👉 APIレスポンスのスキーマを定義
class AddressDict(TypedDict):
    zipcode: str
    prefecture: str
    prefecture_kana: str
    city: str
    city_kana: str
    town: str
    town_kana: str

# 👉 関数の引数にも型を付けておく
def fetch_and_format_address(zipcode: str, include_kana: bool):
    """郵便番号から住所を取得し、整形して返す"""

    api_url = "https://api.zipcode-jp.example/v1/address"

    try:
        response = requests.post(api_url, json={"zipcode": zipcode})
        # ...

        # 👉 .json() の戻り値には上部で定義した型(AddressDict)を注釈する
        # その結果、型チェッカーは response.json() の返却値が AddressDict のスキーマであることを認識する
        address_data: AddressDict = response.json()

        prefecture = address_data["prefecture"]
        city = address_data["city"]
        town = address_data["town"]

        # ...

この変更で、辞書は「何でも箱」から構造が保証されたデータへと変わりました。

AddressDict という型を注釈したことで、address_dataという変数がどのようなデータ構造を持つべきかがコード上で明確に表現されたのです。
address_data["prefectur"] のようなタイプミスや、スキーマに存在しないキーへのアクセスがあれば、型チェッカーが即座に指摘してくれます。プログラムを実行する前に間違いを発見できるのです。

得られたものと、次の課題

今回のリファクタリングでこのコードは辞書のキーに対する静的な型安全性を手に入れました。
その結果、開発者のタイプミス/存在しないキーアクセスによる KeyError は mypy が事前に検出してくれます。

しかしこのリファクタリングは新たな課題を浮き彫りにしました。KeyError という 1 つの脅威は去りましたが、まだコード内には以下の脅威が潜んでいます。

fetch_and_format_address 関数の一部
def fetch_and_format_address(zipcode: str, include_kana: bool):
    # ...
    try:
        response = requests.post(api_url, json={"zipcode": zipcode})
        if response.status_code != 200:
            print(f"Error: Failed to fetch address. Status: {response.status_code}")
            return None  # 👈 None を返している
        # ...
    except requests.exceptions.RequestException as e:
        print(f"An error occurred during API request: {e}")
        return None  # 👈 ここでも None を返している
呼び出し側
result = fetch_and_format_address("9999999", include_kana=True)
print(result)  # None かもしれない! 😱

上記の fetch_and_format_address 関数は、エラー時に None を返すことがあります。
しかし戻り値の型が明示されていないため、呼び出し側は None チェックを忘れる可能性があります。

TypedDict は辞書の「中身の形」を保証してくれましたが、「値が存在しないかもしれない」という可能性までは表現できません。
この問題は TypedDict だけでは解決できないのです。

データの中身は安全になりました。しかしデータの「存在」そのものをどうすれば安全に扱えるのでしょうか?

次回予告

明日の 5 日目ではこの問題を解決するため、「値が存在しないかもしれない」という可能性を型で安全に表現する Union 型を導入していきます。

処方後のコードはこちら
address_fetcher.py
import requests
import json
from typing import TypedDict

# 👉 APIレスポンスのスキーマを定義
class AddressDict(TypedDict):
    zipcode: str
    prefecture: str
    prefecture_kana: str
    city: str
    city_kana: str
    town: str
    town_kana: str

def _build_full_address(address_data: AddressDict) -> str:
    """
    住所データからフル住所文字列を生成する。
    """
    return address_data["prefecture"] + address_data["city"] + address_data["town"]

def fetch_and_format_address(zipcode: str, include_kana: bool):
    """郵便番号から住所を取得し、整形して返す"""

    api_url = "https://api.zipcode-jp.example/v1/address"

    try:
        response = requests.post(api_url, json={"zipcode": zipcode})
        if response.status_code != 200:
            print(f"Error: Failed to fetch address. Status: {response.status_code}")
            return None

        # 👉 .json()の戻り値に、定義した型を注釈する
        address_data: AddressDict = response.json()

        # 👉 キーアクセスは型チェッカーによって安全性が保証された
        full_address = _build_full_address(address_data)

        result = {
            "zipcode": zipcode,
            "full_address": full_address,
            "prefecture": address_data["prefecture"],
            "city": address_data["city"],
            "town": address_data["town"],
        }

        if include_kana:
            result["full_address_kana"] = (
                address_data["prefecture_kana"]
                + address_data["city_kana"]
                + address_data["town_kana"]
            )

        return json.dumps(result, indent=2, ensure_ascii=False)

    except requests.exceptions.RequestException as e:
        # APIリクエスト時の例外を処理
        print(f"An error occurred during API request: {e}")
        return None
    except (KeyError, IndexError) as e:
        # 👉 ここの KeyError の防御は APIが約束したスキーマを破ってきたケースを想定している
        # 開発者のタイプミスはmypyで潰せるが、APIの契約違反は実行時に検知するしかないので
        raise RuntimeError(f"Unexpected response schema: {e}") from e

# 実行例
if __name__ == "__main__":
    result = fetch_and_format_address("1000001", include_kana=True)
    if result is not None:
        print(result)

Discussion