Pythonの型ヒントと共に進化するコード(#6: NewType と TypeAlias)
これまでの連載記事
前回の 5 日目の記事ではUnion と None を使いこなし、関数が None を返す可能性を型で表現しました。
この時点でコードはデータの「形」と「存在」の両方について安全性を手に入れました。
この章では安全性から一歩進んで、コードの「意図」と「意味」をより豊かに表現する方法を取り入れます。
今回の課題:曖昧で誤解を招く型
ここで再び関数のシグネチャ(引数の定義部分)に注目してみましょう。
# address_fetcher.py の一部
def fetch_and_format_address(zipcode: str, include_kana: bool) -> str | None:
# ...
この型注釈は型チェッカーを通過させるという点では正しいのですが、いくつかの重要な情報が欠落しています。
1. str が持つ意味の欠如
zipcode: str という注釈は、この引数が文字列であることしか教えてくれません。
しかし、この関数に渡したいのは単なる文字列ではなく「郵便番号」という特別な意味を持つ文字列です。
もし将来、このアプリケーションが成長して「都道府県コード (prefecture_code)」も str 型として扱うようになったらどうでしょう。
# 関数の引数に prefecture_code が追加された
def fetch_and_format_address(zipcode: str, prefecture_code: str, include_kana: bool) -> str | None: ...
開発者がうっかり fetch_and_format_address(prefecture_code, ...) と書いてしまっても型チェッカーはprefecture_codeもstrなのでこの致命的な間違いを見過ごしてしまいます。
2. HTTP ヘッダーを追加したい場合
将来、API 呼び出しに認証ヘッダーを追加する必要が出てきたとします。
その場合は以下のように引数にheadersという辞書を受け取るよう関数を変更することになるかもしれません。
# 関数の引数に headers が追加された
def fetch_and_format_address(zipcode: str, headers: dict, include_kana: bool) -> str | None: ...
その際、headers: dict という注釈では曖昧です。これは「headers は辞書である」ということしか伝えてくれません。
この辞書のキーは文字列でしょうか? 値は文字列でしょうか、それとも整数も含まれるのでしょうか。
この関数の使い方を正しく知るには、結局コードの中身を読むかドキュメントを探す等するしかありません。
理想的には、関数のシグネチャを見るだけで引数や戻り値の意味がわかるとうれしいです。
これらの問題はコードの安全性を高めた今、次に取り組むべき可読性と保守性の課題と言えそうです。
処方箋:ドメインの意図を込めるNewTypeとTypeAlias
この課題を解決するため NewType と TypeAlias を使います。
1. NewType: 新しい意味を持つ型を作る
NewTypeは、既存の型(例: str)を基に、型チェッカーが全く別の新しい型として認識する型を作成する機能です。
from typing import NewType
# ZipCodeという新しい型を定義。実行時にはただのstrだが、
# 型チェック時にはstrとは区別される。
ZipCode = NewType("ZipCode", str)
def get_address(zipcode: ZipCode) -> str:
# ...
return "東京都千代田区千代田"
# 正しい使い方
zipcode: ZipCode = ZipCode("1000001")
get_address(zipcode)
# 間違った使い方
zipcode_str: str = "1000001"
# get_address(zipcode_str) -> これは型エラー!
# --> 'ZipCode' 型を期待する引数に 'str' 型を渡すことはできない
NewTypeを使うことで見た目はどちらもstrですが、
- 「郵便番号として扱いたい文字列(
ZipCode)」 - 「都道府県コードとして扱いたい文字列(
PrefectureCode)」
を型レベルで区別できるようになります。
この意味の違う文字列を別の型として扱うという発想がまさにNewTypeの価値です。
ちなみに、冒頭で示した「引数にprefecture_codeを追加した場合」の例でも考えてみましょう。
PrefectureCode = NewType("PrefectureCode", str)
ZipCode = NewType("ZipCode", str)
def fetch_and_format_address(zipcode: ZipCode, prefecture_code: PrefectureCode, ...) -> str | None: ...
# 引数を逆にした場合…
zipcode = ZipCode("1000001")
pref_code = PrefectureCode("13")
fetch_and_format_address(pref_code, zipcode, ...) # 型エラー!
実際に mypy を実行すると、以下のようなエラーが出力されます。
error: Argument 1 to "fetch_and_format_address" has incompatible type "PrefectureCode"; expected "ZipCode" [arg-type]
error: Argument 2 to "fetch_and_format_address" has incompatible type "ZipCode"; expected "PrefectureCode" [arg-type]
両方ともstrなのに型チェッカーが間違いを検出してくれます。
2. TypeAlias: 複雑な型に分かりやすい別名をつける
実は、先ほどの headers: dict は headers: dict[str, str | int] のようにキーと値の型を明示できます。ただし、複雑な型を毎回書くのは手間ですし、可読性も下がります。
TypeAliasは、こうした複雑な型定義に分かりやすい別名を与える機能です。Python 3.12 以降ではtype文を使ってシンプルに記述できます。
(Python 3.10 〜 3.11 ではHeaders: TypeAlias = dict[str, str | int] のようにtyping.TypeAliasを使って明示的に定義します)
たとえば、TypeAlias を使わずに複雑な型をそのまま書くとどうなるでしょうか。
# TypeAliasを使わない場合
def send_request(
url: str,
headers: dict[str, str], # 毎回これを書くの?
payload: dict[str, str | int | float | bool | None], # 長すぎて読みづらい...
) -> dict[str, str | int | float | bool | None]:
# ...
pass
型定義が長すぎて関数の本来の役割が見えにくくなってしまいます。
さらに、同じ型定義を複数の場所で使っている場合は仕様変更のたびにすべてを書き換える必要があり、メンテナンスコストも増えてしまいます。
TypeAlias を使えば以下のようにスッキリと整理できます。
# HTTPヘッダーの型に'Headers'という別名をつける
type Headers = dict[str, str]
# 複数のプリミティブ型を許容するJSONの値の型
type JsonValue = str | int | float | bool | None
def send_request(
url: str,
headers: Headers, # 👈 スッキリして、
payload: dict[str, JsonValue], # 👈 意味も明確
) -> dict[str, JsonValue]:
# ...
pass
TypeAliasは、NewTypeと違って新しい型を作るわけではなく、あくまで別名です。
しかし、dict[str, str | int]のような複雑な型が何度も登場する場合、Headersのように意味のある名前を与えることでコードの可読性を劇的に向上させることができます。
上記の HTTP ヘッダーのようにほぼ全てのリクエストで同じ形を取るデータにはHeadersのような型エイリアスを 1 箇所で定義しておくと、以降のコードから「これは HTTP ヘッダーだ」とすぐに読み取れるようになります。
コードの進化:スクリプトへの適用
それでは、NewTypeとTypeAliasをaddress_fetcher.pyに適用し、関数の意図をより明確にしていきましょう。
変更前のコード
def fetch_and_format_address(zipcode: str, include_kana: bool) -> str | None:
# ...
NewTypeとTypeAliasの適用
モジュールのトップレベルでZipCodeとHeadersを定義し、関数のシグネチャを更新します。また、将来の拡張に備えて HTTP ヘッダーを受け取れるようにします。
# ...
from typing import TypedDict, NewType
# 👉 NewTypeでドメイン固有の型を定義
ZipCode = NewType("ZipCode", str)
# 👉 type文でヘッダーの型に別名を定義
type Headers = dict[str, str]
# ... (TypedDictの定義) ...
# 👉 関数のシグネチャを新しく定義した型で更新
def fetch_and_format_address(
zipcode: ZipCode, # 👈 NewTypeで定義した ZipCode を指定
include_kana: bool,
headers: Headers | None = None, # 👈 オプショナルなヘッダーを追加
) -> str | None:
# ...
この小さな変更がコードの明確性に大きな違いを生みます。関数のシグネチャを見ただけでzipcodeは単なる文字列ではなくZipCode型であり、headersは文字列から文字列への辞書であることが一目瞭然になりました。
得られたものと、次の課題
今回のリファクタリングでコードは 「意図」と「意味」を型で表現できる ように進化しました。
NewTypeによって型の安全性が向上し、TypeAliasによって可読性が高まりました。コードはもはや単に処理を記述するだけでなく、ドメインの概念が反映されている状態になりました。
データの形、存在、そして意味。これらを型で表現できるようになった今、次はコードの構造に目を向けてみます。
現在のaddress_fetcher.pyでは、HTTP リクエストを行うrequests.post(...)が関数内に直接埋め込まれています。
def fetch_and_format_address(
zipcode: ZipCode,
include_kana: bool,
headers: Headers | None = None,
) -> str | None:
# ...
try:
# 🤔'requests'という具体的なライブラリに強く依存している
response = requests.post(api_url, json={"zipcode": zipcode}, headers=headers)
# ...
例えば、この関数の品質を担保するために自動テストを書きたい場合、どうなるでしょうか。requests ライブラリが関数内部にハードコードされているため、テスト時にも毎回ネットワーク越しに本物の API サーバーへ問い合わせることになります。外部 API 側のメンテナンスやネットワークの都合で通信できなければ内部ロジックに問題がなくてもテストが失敗してしまいます。
本質的には、内部の挙動を変えずに想定したレスポンスを差し込めるようにしたいのです。
どうすればこのrequestsライブラリへの直接的な依存を断ち切り、コードをよりテストしやすく、柔軟な構造にできるのでしょうか?
次回予告
次回からは上記の課題に取り組みます。requests への依存を抽象化し、テスト時には別の実装に差し替えられる構造を目指します。Python でこれを実現するには大きく 2 つのアプローチがあります。まず従来の方法である ABC(抽象基底クラス) を試し、その後 Protocol と比較していきます。
処方後のコードはこちら
import requests
import json
from typing import TypedDict, NewType
# 👉 NewTypeでドメイン固有の型を定義
ZipCode = NewType("ZipCode", str)
# 👉 type文でヘッダーの型に別名を定義
type Headers = dict[str, str]
# APIレスポンスのスキーマを定義
class AddressDict(TypedDict):
zipcode: str
prefecture: str
prefecture_kana: str
city: str
city_kana: str
town: str
town_kana: str
# 出力結果のスキーマを定義
class FormattedAddressDict(TypedDict):
zipcode: str
full_address: str
prefecture: str
city: str
town: str
class FormattedAddressWithKanaDict(FormattedAddressDict):
full_address_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: ZipCode,
include_kana: bool,
headers: Headers | None = None,
) -> str | None:
"""郵便番号から住所を取得し、整形して返す"""
api_url = "https://api.zipcode-jp.example/v1/address"
try:
response = requests.post(
api_url,
json={"zipcode": zipcode},
headers=headers,
)
if response.status_code != 200:
print(f"Error: Failed to fetch address. Status: {response.status_code}")
return None
address_data: AddressDict = response.json()
full_address = _build_full_address(address_data)
result: FormattedAddressDict = {
"zipcode": zipcode,
"full_address": full_address,
"prefecture": address_data["prefecture"],
"city": address_data["city"],
"town": address_data["town"],
}
if include_kana:
result_with_kana: FormattedAddressWithKanaDict = {
**result,
"full_address_kana": (
address_data["prefecture_kana"]
+ address_data["city_kana"]
+ address_data["town_kana"]
),
}
return json.dumps(result_with_kana, indent=2, ensure_ascii=False)
return json.dumps(result, indent=2, ensure_ascii=False)
except requests.exceptions.RequestException as e:
print(f"An error occurred during API request: {e}")
return None
except KeyError as e:
print(f"Error processing data: Invalid data structure - {e}")
return None
# 実行例
if __name__ == "__main__":
# 👉 ZipCodeを使って、ただのstrではないことを明示
zipcode = ZipCode("1000001")
result = fetch_and_format_address(zipcode, include_kana=True)
if result is not None:
print(result)
Discussion