Python型ヒント×mypyで静的型チェック|実践的な導入方法と注意点

プログラミング

Pythonで「なんで動かないんだ…あ、文字列渡してた」みたいなミスを繰り返しているうちに、さすがにそろそろ型チェックちゃんとやろうかと思い立ちました。というわけで、型ヒントとmypyについて自分なりに整理してみます。

この記事でわかること:

  • Python型ヒントの基本的な書き方(現代的な記法)
  • mypyのインストールと基本的な使い方
  • pyproject.tomlでの設定方法
  • 実際にどんなエラーを検出してくれるか
  • GitHub Actionsへの組み込み方

そもそもなぜ型ヒントが必要なのか

Pythonは動的型付けの柔軟さが魅力ですが、規模が大きくなるほど型まわりのバグや可読性の低下が問題になりがちです。短いスクリプトを書いている分には全然気にならないんですが、関数が増えてきたり複数ファイルにまたがったりし始めると「この引数って何を渡せばいいんだっけ」ってなる。

「実行するまで(あるいは単体テストを回すまで)間違いに気づけない」という問題に対して、mypyを導入することで遭遇したバグを事前に検出できます。mypyを導入した開発スタイルでは、コードを書いたあと、mypyで型チェックを行い、実行する前に「あり得ない型の組み合わせ」を検出します。エラーを修正してから実行することで、実行時エラーの多くを未然に防ぐことができるわけです。

要は「実行するまで気づけない」をゼロにはできないにしても、かなり前倒しで気づける、というのが型チェックの本質だと思っています。

型ヒントの基本的な書き方

型ヒント(Type Hints)は、関数の引数や戻り値、変数にどのような型のデータが入るかを注釈として記述する機能です。Python 3.5でPEP 484として導入されました。

まず基本形から。シンプルな関数への型注釈です。

def greet(name: str) -> str:
    return f"Hello, {name}"

def add(x: int, y: int) -> int:
    return x + y

# 変数にも付けられる
count: int = 0
items: list[str] = []

ひとつ注意点として、昔の書き方と今の書き方の違いがあります。PEP 585によりtyping.Listの代わりにlistを直接型ヒントとして使用できるようになりました。typing.Listなどの標準コレクションのエイリアスは今後利用頻度が下がるにつれて削除される可能性があります。

# 古い書き方(Python 3.8以前の名残)
from typing import List, Optional
def old_style(items: List[str]) -> Optional[str]: ...

# 今の書き方(Python 3.10+)
# ※list[str] はPython 3.9+、str | None はPython 3.10+ で使えます
def new_style(items: list[str]) -> str | None: ...

Optional[str]よりstr | Noneのほうが直感的でわかりやすい気がします。個人的にはパイプ記法のほうが好きです。

よく使う型の一覧

from typing import Any

nums: list[int] = [1, 2, 3]
scores: dict[str, float] = {"math": 90.0}
pair: tuple[int, str] = (1, "a")
value: str | None = None          # None許容
data: Any = something_dynamic()   # 何でもあり(極力使いたくない)

Anyを使いすぎると型チェックの恩恵がほぼ消えるので、本当に仕方ないときだけにしたほうがいいです。わかってても使いたくなることはあるんですが…。

mypyのインストールと基本的な動かし方

mypyはPythonのコードに対して静的な型チェックを行うツールです。Python 3.5以降で導入された型ヒントを利用して、コード中の型に関する誤りを検出します。これにより、型に関連するバグを事前に防ぐことができます。

インストールはpipで一発です。

pip install mypy

実行はこう。

# 単一ファイル
mypy main.py

# ディレクトリ全体
mypy src/

# 厳格モード(全部チェック)
mypy --strict main.py

試しにわざとミスのあるコードを書いてチェックしてみます。

# sample.py
def greet(name: str) -> str:
    return f"Hello, {name}"

result = greet(42)  # ここ注意:intを渡している
$ mypy sample.py
sample.py:4: error: Argument 1 to "greet" has incompatible type "int"; expected "str"
Found 1 error in 1 file (checked 1 source file)

実行前にちゃんと怒ってくれます。これ、地味に気持ちいい。

pyproject.tomlで設定を管理する

毎回コマンドラインオプションを渡すのは面倒なので、設定ファイルに書いておきます。最近はmypy.iniよりpyproject.tomlにまとめるほうが主流っぽいです。

[tool.mypy]
python_version = "3.12"
strict = true
ignore_missing_imports = false
warn_return_any = true
warn_unused_ignores = true

strict = trueにすると一気にいろんなチェックが有効になります。--strictを付けると引数と戻り値の型ヒントが必要になり、付いていないとエラーが発生します。既存プロジェクトに後から入れると一気にエラーが大量発生して心が折れるので注意です。

段階的に導入するなら、まずこのくらいから始めるのがおすすめです。

[tool.mypy]
python_version = "3.12"
disallow_untyped_defs = true   # 型ヒントなし関数をエラーに
no_implicit_optional = true
warn_return_any = true
warn_unused_ignores = true

# サードパーティライブラリへの対応(必要箇所だけ)
[[tool.mypy.overrides]]
module = ["boto3.*", "botocore.*"]
ignore_missing_imports = true

まずはdisallow_untyped_defsなどから始め、徐々に厳格化し、外部ライブラリはピンポイントでignore_missing_importsを設定するのがよいアプローチです。

AWSのSDKであるboto3はstubが別パッケージ(boto3-stubs)に分かれているので、使うサービスに応じてインストールする必要があるようです。これがちょっと面倒ではある。

# 使うAWSサービスに合わせてインストール
pip install boto3-stubs[lambda,s3,dynamodb]

mypyが検出してくれるエラーの例

実際どんなものを拾ってくれるか、いくつか示しておきます。

# 戻り値の型不一致
def get_user(user_id: int) -> str:
    users = {1: "Alice"}
    return users.get(user_id)  # Optional[str]なのにstrを期待している
error: Incompatible return value type (got "str | None", expected "str")

これ、実際に踏んだことあります。dict.get()ってNoneを返す可能性があるんですよね。型チェックがなかった頃は実行してNoneErrorが出るまで気づかなかった。

# Noneチェックを追加すれば解決
def get_user(user_id: int) -> str:
    users = {1: "Alice"}
    result = users.get(user_id)
    if result is None:
        raise ValueError(f"User {user_id} not found")
    return result

こうすると型が絞り込まれて(narrowing)、mypyもOKと言ってくれます。

もう一つよくある例。

# 引数の取り違え
def send_message(user_id: int, message: str) -> None:
    ...

# 順番が逆
send_message("hello", 123)
error: Argument 1 to "send_message" has incompatible type "str"; expected "int"
error: Argument 2 to "send_message" has incompatible type "int"; expected "str"

mypyは実行やテストの前に「引数の取り違え」や「演算子の型不一致」を警告してくれます。こういう間違いって実行してみるまで気づかないことが多いので、地味にありがたいです。

GitHub Actionsに組み込む

せっかく設定したのでCIにも入れておきます。プルリクを出したタイミングで自動チェックが走るようにするのが目標です。

# .github/workflows/type-check.yml
name: Type Check

on:
  push:
    branches: [main]
  pull_request:

jobs:
  mypy:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - uses: actions/setup-python@v5
        with:
          python-version: "3.12"

      - name: Install dependencies
        run: |
          pip install mypy
          pip install -r requirements.txt

      - name: Run mypy
        run: mypy src/

これで型エラーがあるコードをmainに混入させるのをある程度防げます。CIでmypyを--strictで実行し、PRマージをブロックする、pyproject.tomlでツール設定を一元管理する、というのが実践的なアプローチです。

pre-commitと組み合わせてコミット時にもチェックするようにすると、さらに早い段階で気づけます。

# .pre-commit-config.yaml
repos:
  - repo: https://github.com/pre-commit/mirrors-mypy
    rev: v1.9.0
    hooks:
      - id: mypy
        args: [--config-file=pyproject.toml]
        additional_dependencies: [types-requests]

mypyの限界と正直な感想

使ってみてよかったとは思うのですが、万能ではないです。

多くのサードパーティライブラリに型定義がなく、mypyがチェックできない部分が多い。ignore_missing_importsを多用せざるを得ず、「せっかくの静的解析なのに…」という気持ちになりやすい。

リフレクション、動的属性追加、メタクラスなどは解析できず、常にエラー扱いになる。型定義の欠落や推論の限界により、どうしても抑えきれないエラーが出る。放置すると「本当に必要な無視」と「雑に消した無視」が混在して管理が難しくなる。

あと個人的に、既存のコードに後から型ヒントを足す作業がなかなかつらい。新規プロジェクトから入れるのが本当に楽です。途中から導入するなら、ファイル単位か機能単位で少しずつ足していくのが精神衛生上よいと思います。

Python 3.10〜3.12にかけて、型システムはさらに大きな進化を遂げました。match文との連携、ParamSpecやTypeVarTupleによる高度なジェネリクス、そしてPython 3.12で導入されたtype文による新しいシンタックス——これらを使いこなすことで、型安全性と表現力の両立が初めて現実的になってきました。

高度な機能についてはまだ自分も全部把握できていないので、必要になったら都度調べる感じで進めています。TypeVarとかジェネリクスまわりは正直まだぼんやりしている部分が多い。

※この記事にはプロモーションが含まれます

ちなみに、お名前.com レンタルサーバー(WordPressに特化した高速レンタルサーバー。月額990円〜、独自ドメイン実質0円)も気になっています。お名前.com レンタルサーバー

まとめ

今のところ「関数の引数と戻り値にはちゃんと型を書く」「Noneになる可能性がある変数には| Noneを付ける」「CIでmypyを走らせる」この3つだけでも十分恩恵を感じています。完璧を目指すより、少しずつ型のカバレッジを上げていく方向で続けてみようと思います。

参考になったらクリックしてもらえると嬉しいです!

Blogmura ProgrammingProgramming Ranking
タイトルとURLをコピーしました