Python大規模開発の鍵!?:最新の型ヒントで実現する型安全なコード

はじめに

はじめまして、テックドクターでエンジニアリングマネージャをしている星野です。

弊社ではPythonを活用することが多く、型ヒントを積極的に導入し、型安全なコードの実現に努めています。

Pythonの型ヒントはPython 3.5(2015年9月リリース)から導入されましたが、その後も継続的に機能追加が行われ、使いやすく進化しています。

本記事では、型ヒントの基本的な説明に加え、最新バージョンでの改善点を紹介します。

型ヒントとは

Pythonは動的型付け言語のため、変数の型を指定する必要がありません。

そのためコードの記述が簡潔になりますが、一方で実行するまで型エラーが検出できず、予期しないバグの原因となることがあります。

大規模開発では、実行するまで動作が保証されないことが大きなリスクとなるため、Python 3.5 から型ヒントが導入されました。型ヒントを活用することで、実行前に型の整合性をチェックできます。

  • 型ヒントを使わない場合の書き方
def join_comma(lst):
  return ",".join(lst)

  • 型ヒントを使う場合の書き方
def join_comma(lst: list[str]) -> str:
  return ",".join(lst)

このように、型ヒントを追加することで、引数や戻り値の型を明示でき、コードの可読性と安全性が向上します。

ただし、Python自体は型ヒントを実行時に考慮しないため、静的型チェックツール(mypy など)を活用する必要があります。

Pythonでの静的型チェックツール

Pythonには標準の型チェック機能がないため、外部ツールを使用して型チェックを行います。

弊社ではGitHub Actions に静的型チェックツールを組み込み、CI/CDの一環として実行することで必ずチェックできるようにしています。

静的型チェックツールとしては以下があります。

  • mypy : 静的型チェックツールのリファレンス実装。歴史が長く、利用者が多い。
  • pyrightMicrosoft製。高速な型チェックが特徴。VS Code拡張機能 Pylance にも組み込まれている。
  • pyre:Meta製。OCaml で実装され、パフォーマンスを重視。
  • pytypeGoogle製。型ヒントがなくても型を推論可能。

弊社では mypy を主に使用していますが、高速な pyright も試してみたいと考えています。

型ヒントの進化

ここからは、Python3.9以降で加えられた改良の中からいくつかピックアップして紹介していきます。

標準コレクション型の記法変更(Python3.9〜)

PEP 585 – Type Hinting Generics In Standard Collections によりtyping.List の代わりに list を直接型ヒントとして使用できるようになりました。

  • Python3.8以前の書き方
from typing import List

def join_comma(lst: List[str]) -> str:
    return ",".join(lst)

  • Python3.9以降の書き方
def join_comma(lst: list[str]) -> str:
    return ",".join(lst)

typing.List は非推奨となったため、新しい記法を使用しましょう。
typing.Listなどの標準コレクションのエイリアスは今後利用頻度が下がるにつれて削除される可能性があります。

Unionの記法(Python3.10〜)

PEP 604 – Allow writing union types as X | Y により、Union の代わりに |(パイプ演算子)を使用できるようになりました。

この導入によって、Noneを取るかもしれない型の表現としては以下の3パターンになっています。

  • typing.Union[int,None]
  • typing.Optional[int]
  • int | None

  • typing.Unionを利用した書き方
from typing import Union

def join_comma(lst: Union[list[str],None]) -> Union[str,None]:
  return ",".join(lst) if lst is not None else None

  • typing.Optionalを利用した書き方
from typing import Optional

def join_comma(lst: Optional[list[str]]) -> Optional[str]:
    return ",".join(lst) if lst is not None else None

  • 「|」を利用した書き方
def join_comma(lst: list[str] | None) -> str | None:
    return ",".join(lst) if lst is not None else None

どの表現が推奨というのはないのですが、チーム内ではどれかに統一されているほうが良いと思います。
個人的には、「|」がシンプルかつ、分かりやすく表現されていると思います。

type文による型エイリアス(Python3.12〜)

PEP 695 – Type Parameter Syntax で型エイリアスを type 文で定義できるようになりました。

エイリアスは、最初は変数に型を代入することで実現していました。
ただし見た目が代入文と同じであり紛らわしかったことから、その後、Python3.10でtyping.TypeAlias が導入され、明示的に型として表すことができるようになりました。
Python3.12で、type文が導入されて、代入ではなく、構文として利用することができるようになりました。

typing.TypeAliasは非推奨となったので、今後はtype文を利用していきましょう。

  • Python3.10以前
from typing import Literal

# 代入で型を実現
Environment = Literal["development", "staging", "production", "test", "local"]

env:Environment = "development"

  • Python3.10 - 3.11
from typing import TypeAlias, Literal

# TypeAliasを使うことで、型であることを明示
Environment: TypeAlias = Literal["development", "staging", "production", "test", "local"]

env:Environment = "development"

  • Python3.12以降
from typing import Literal

# type文で型を定義することが可能に
type Environment = Literal["development", "staging", "production", "test", "local"]

env:Environment = "development"


TypedDictについて

TypedDictはdictに対して、より細かい型を指定することができます。

例えば、TypedDictを使わない場合、名前と年齢を表すようなデータを定義しようとすると以下のようになります。

person1: dict[str, str|int] = { "name": "Yamada", "age": 28 }


名前と年齢とで型が違うため、値に文字列か数値を取るdict型となります。
そのため、nameというキーに対して数値を設定しても型チェックとしてはエラーは出ません。

person1: dict[str, str|int] = { "name": "Yamada", "age": 28 }
person1["name"] = 1 # mypyなどの型チェックでもエラーは出ない


ですが、TypedDictを使うと、以下のように各キーに対して型を定義することができます。

from typing import TypedDict

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

person1: Person = {"name": "Yamada", "age": 28}
person1["name"] = 1  # mypyでエラーになる


上記コードでmypyを実行すると以下のエラーが出てくれます。

 error: Value of "name" has incompatible type "int"; expected "str"  [typeddict-item]


NotRequiredについて(Python3.11〜)

PEP 655 – Marking individual TypedDict items as required or potentially-missingにより、TypedDictのキーを任意項目として定義できるようになりました。

例えば、以下のコードはgender キーが省略されているので、mypyでエラーが発生してしまいます。

from typing import TypedDict, Literal

class Person(TypedDict):
    name: str
    age: int
    gender: Literal["male", "female", "other"]|None

person1: Person = {"name": "Yamada", "age": 28, "gender": None } # genderキーがあるのでOK
person2: Person = {"name": "Yamada", "age": 28} # genderキーが無いのでエラー


NotRequiredを利用すると利用すると、キーが省略可能になります。

from typing import TypedDict, NotRequired, Literal

class Person(TypedDict):
    name: str
    age: int
    gender: NotRequired[Literal["male", "female", "other"]]

person1: Person = {"name": "Yamada", "age": 28} # genderキーが無くてもOK


また、Required も導入されており、必須項目が少ない場合は total=False と組み合わせると分かりやすくなります。

totalをFalseにすると全てのキーが必須ではなくなるので、Requiredで必須項目を指定する形になります。

from typing import TypedDict, Required, Literal

class Person(TypedDict, total=False):
    name:Required[str]
    age:Required[int]
    gender: Literal["male", "female", "other"]

person1: Person = {"name": "Yamada", "age": 28} # genderキーが無くてもOK
person2: Person = {"name": "Yamada", "gender": "male"} # ageキーが無いのでエラー


Unpackと併用して**kwargs引数に型を追加(Python3.12〜)

PEP 692 – Using TypedDict for more precise **kwargs typingにて、TypedDictとUnpackを利用して**kwargs引数に対してより厳密な型指定が可能になりました。
今までは単一の型しか指定できず、さらに仕様にない引数を指定してもエラーになりませんでした。

  • Unpackを使わない場合
from typing import TypedDict, NotRequired, Literal, Unpack

def print_person(**kwargs: str|int) -> None:
    print(kwargs)
    return

print_person(name="Yamada", age=28)  # OK
print_person(name=1, age=28)         # nameにint入ってしまうがエラーは出ない
print_person(name="Yamada", aeg=28)  # ageを誤字してしまっているがエラーは出ない

  • Unpackを使う場合
from typing import TypedDict, NotRequired, Literal, Unpack

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

def print_person(**kwargs: Unpack[Person]) -> None:
    print(kwargs)
    return


print_person(name="Yamada", age=28)  # OK
print_person(name=1, age=28)         # nameにintが指定されているのでエラー
print_person(name="Yamada", aeg=28)  # ageを誤字してしまっているのでエラー


読み取り専用のアイテム定義:ReadOnly(Python3.13〜)

PEP 705 – TypedDict: Read-only itemsにより、一部のキーを読み取り専用に設定できるようになりました。

一部のキーだけ値の変更をしたくない場合に利用します。

from typing import TypedDict, ReadOnly, NotRequired, Literal

class Person(TypedDict):
    name:ReadOnly[str]
    age: int
    gender: NotRequired[Literal["male", "female", "other"]]

person1: Person = {"name": "Yamada", "age": 28, "gender": "male"}
person1["age"] = 30  # OK
person1["name"] = "Tanaka"  # nameはReadOnlyなのでエラー

まとめ

バージョンアップする毎に型ヒントが色々と改良されており、自身でもきちんと把握したいと思い今回の記事を執筆しました。

TypedDictについては、今回執筆するにあたって初めて知った機能です。
既存のコードで既にdictを使っている部分に型を導入する際、既存のコードへの変更なしに型安全にすることができるので、試してみたいです。

今回は、Generic関係については触れられていないのでどこかでまとめたいと思います。