■はじめに
Python型システムについて、学んだのでその備忘録として残します。
■環境
- OS :macOS 26.3.1 (Build 25D2128)
- アーキテクチャ:arm64 (Apple Silicon)
- Python:3.12.13
- uv:0.10.9
■Python型システムの3層構造
- 第1層: 型ヒントを書く
- 標準ライブラリ typing モジュール
x: int, def f(x: str) -> bool
- 第2層: 静的解析ツールで型エラーをチェック
- mypy / pyright / pyrefly / ty 等
- 実行前にバグを発見
- 第3層: ランタイムでの型検証
- Pydantic
- 外部入力 (API, DB, JSON) を検証
Pythonの型システムは3つの層があります。
1層+第2層(型ヒント+型チェッカー)は、実行前にエラー検出します。実行時は何もしないです。
第3層(Pydantic)は、実行時に外部データを検証・変換をします。
型を書く理由は
- バグを実行前に発見できる(型不一致、None忘れ)
- エディタの補完が劇的に賢くなる(VSCode/PyCharm)
- コードの意図が明確になる(読み手のためのドキュメント)
- リファクタリング時の安全性が増す
■型ヒントを書く(typing モジュール)
◆変数と関数
# ============================================
# 1. 変数の型ヒント
# ============================================
name: str = "Junya"
age: int = 28
score: float = 95.5
is_active: bool = True
# ============================================
# 2. 関数の型ヒント
# ============================================
def greet(name: str, times: int = 1) -> str:
"""名前を times 回繰り返してあいさつを返す"""
return (f"Hello, {name}!" + " ") * times
def log(message: str) -> None:
"""戻り値が無い関数は None"""
print(f"[LOG] {message}")
TypeScriptと似たような型の書き方をします。
◆コレクション型
# ============================================
# 3. コレクション型 (Python 3.9+)
# ============================================
# 古い書き方: from typing import List, Dict
# 新しい書き方: 組み込み型をそのまま使う
names: list[str] = ["Alice", "Bob", "Charlie"]
ages: dict[str, int] = {"Alice": 30, "Bob": 25}
coords: tuple[float, float] = (35.6762, 139.6503)
unique_ids: set[int] = {1, 2, 3}
Python 3.9+とそれ以外では書き方が違いますが、上のように書きます。
◆Optional / Union型
# ============================================
# 4. Optional / Union (Python 3.10+)
# ============================================
def find_user(user_id: int) -> str | None:
"""ユーザーが見つからなければ None を返す"""
if user_id == 1:
return "Junya"
return None
def parse_value(value: int | str) -> str:
"""int でも str でも受け取れる"""
return str(value)
str | Noneやint | strがOptional / Union型です。
◆Callable(関数型)
# ============================================
# 5. Callable(関数型)
# ============================================
from typing import Callable
def apply_operation(
fn: Callable[[int, int], int], # int 2つ取ってint返す関数
a: int,
b: int,
) -> int:
return fn(a, b)
apply_operation(lambda x, y: x + y, 1, 2) # 3
Callable[[int, int], int]は、2つの引数を受け取って、int型を返すという意味になります。
◆Literal型
# ============================================
# 6. Literal(特定の値のみ許可)
# ============================================
from typing import Literal
def open_file(path: str, mode: Literal["r", "w", "a"]) -> None:
"""mode は 'r' / 'w' / 'a' のどれかしか許さない"""
print(f"open({path}, {mode!r})")
open_file("data.txt", "r") # ✓ OK
open_file("data.txt", "x") # ✗ 型チェッカーがエラー
◆TypedDict型
# ============================================
# 7. TypedDict(辞書の構造を型で表現)
# ============================================
from typing import TypedDict
class UserDict(TypedDict):
id: int
name: str
email: str
def show_user(user: UserDict) -> None:
print(f"#{user['id']} {user['name']} <{user['email']}>")
user: UserDict = {"id": 1, "name": "Junya", "email": "j@example.com"}
# 必要なキーが欠けていれば型チェッカーがエラー
TypedDict型は、オブジェクトの構造を型で表現する事ができます。
◆dataclass + 型ヒント
from dataclasses import dataclass
@dataclass
class Point:
x: float
y: float
def distance_to(self, other: "Point") -> float:
return ((self.x - other.x) ** 2 + (self.y - other.y) ** 2) ** 0.5
p1 = Point(0.0, 0.0)
p2 = Point(3.0, 4.0)
print(p1.distance_to(p2)) # 5.0
@dataclass は __init__ / __repr__ / __eq__ を自動生成してくれます。
■型エラーを検出する(mypy)
◆mypy のインストールと基本
# uv を使っている場合
uv add --dev mypy # プロジェクトの開発依存に追加
uv run mypy myfile.py # 単一ファイル
uv run mypy src/ # ディレクトリ全体
uv run mypy --strict src/ # 厳格モード
# pip を使う場合(参考)
# pip install mypy
# mypy myfile.py
uv を使う場合、型チェッカーは「開発時にだけ必要」なツールなので、--dev フラグで dev 依存に追加します。
◆mypyのエラー検出を体験する
# ============================================
# エラー1: 引数の型が違う
# ============================================
def add(a: int, b: int) -> int:
return a + b
def example_wrong_arg_type() -> None:
# 文字列を渡している → mypy がエラー
result = add(1, "2")
print(result)
# ============================================
# エラー2: 戻り値の型が違う
# ============================================
def get_user_name() -> str:
return 42
# ============================================
# エラー3: None チェック忘れ
# ============================================
def find_user_name(user_id: int) -> str | None:
if user_id == 1:
return "Junya"
return None
def example_missing_none_check() -> None:
name = find_user_name(99)
# name は str | None だが、None チェックせずに使うと...
print(name.upper())
型の不整合でエラーになるサンプルコードです。
VSCodeなどを使っていると、エディタ上でエラーが表示されます。
これを、mypyの静的解析ツールにかけてみます。
uv run mypy samples/02_mypy_errors.py
実行すると、このように静的解析でエラーが検出されます。
[project]
name = "python-study"
version = "0.1.0"
description = "Add your description here"
readme = "README.md"
requires-python = ">=3.12"
dependencies = [
"pydantic>=2.13.3",
]
[dependency-groups]
dev = [
"ipython>=9.13.0",
"jupyter>=1.1.1",
"mypy>=1.20.2",
"pytest>=9.0.3",
"ruff>=0.15.12",
]
# =============================================================================
# mypy: 静的型チェッカー
# -----------------------------------------------------------------------------
# 目的: 実行前に型不整合・None 安全性違反・到達不能コードなどを検出する。
# 方針: 新規プロジェクトのため、実用上耐えうる最大限の厳しさで運用する。
# Any の侵入を可能な限り遮断し、型情報を強制する。
# =============================================================================
[tool.mypy]
# プロジェクトの想定 Python バージョン。これを基準に文法・標準ライブラリの型が解析される。
python_version = "3.12"
# -----------------------------------------------------------------------------
# ベースライン: strict モード
# -----------------------------------------------------------------------------
# `strict = true` は以下を一括で有効化するエイリアス:
# - warn_unused_configs : 未使用の mypy 設定を警告
# - disallow_any_generics : list, dict など型引数なし generic を禁止
# - disallow_subclassing_any : Any を継承するクラスを禁止
# - disallow_untyped_calls : 注釈なし関数の呼び出しを禁止
# - disallow_untyped_defs : 注釈なし関数定義を禁止
# - disallow_incomplete_defs : 一部だけ注釈ありの関数を禁止
# - check_untyped_defs : 注釈なし関数の中身もチェック
# - disallow_untyped_decorators: 注釈なしデコレータを禁止
# - no_implicit_optional : デフォルト値 None で暗黙的に Optional 化させない
# - warn_redundant_casts : 不要な cast() を警告
# - warn_unused_ignores : 効いていない # type: ignore を警告
# - warn_return_any : Any を返す関数を警告
# - no_implicit_reexport : __init__.py での暗黙再エクスポートを禁止
# - strict_equality : 型が違う値同士の == 比較を警告
# - extra_checks : その他の追加チェック
strict = true
# -----------------------------------------------------------------------------
# Any の侵入を全面遮断
# -----------------------------------------------------------------------------
# strict には含まれない「Any の混入経路」を個別に塞ぐ。
# 型システムの強度は最弱リンク(= Any が紛れ込む箇所)で決まるため、
# 新規プロジェクトでは可能な限り Any を許さない設計が有利。
# `: Any` と明示的に書いた型注釈を禁止する。
# Any を使う場合は型設計をやり直すか、object / TypeVar / Protocol で表現する。
disallow_any_explicit = true
# import に失敗した結果として Any 化することを禁止する。
# 「型情報のないライブラリを黙って使ってしまい、その下流が全部 Any になる」事故を防ぐ。
# 型情報がないライブラリは下部の overrides で明示的に許可する運用にする。
disallow_any_unimported = true
# デコレータの結果として関数が Any 型になるケースを禁止する。
# サードパーティの注釈不備なデコレータで関数の型が消し飛ぶのを防ぐ。
disallow_any_decorated = true
# `disallow_any_expr` は最強だが、json.loads() 等も即 Any として弾くため非常に厳しい。
# Pydantic 等で境界を必ず型付けする設計が徹底できる場合のみ ON にする。
# 本プロジェクトでは現実性を優先して OFF。
# disallow_any_expr = true
# -----------------------------------------------------------------------------
# 追加の安全網(strict に含まれない有用チェックを opt-in)
# -----------------------------------------------------------------------------
enable_error_code = [
"redundant-expr", # 常に同じ値になる無意味な式を検出 (例: x is None or x is None)
"truthy-bool", # `if obj:` のように常に真になる真偽評価を警告
"truthy-iterable", # 空チェックのつもりで誤って常に真になるケース
"ignore-without-code", # `# type: ignore` に必ず [code] を付けさせる
# → 「何を黙らせたか」を後追い可能にする
"unused-awaitable", # await 忘れの coroutine を検出
"possibly-undefined", # 分岐により未定義になり得る変数の使用を検出
"redundant-self", # 不要な Self 型注釈を検出
"explicit-override", # サブクラスで親メソッドを上書きする際 @override を強制 (3.12+)
# → メソッド名 typo による silent な上書き失敗を防ぐ
"mutable-override", # 可変属性をサブクラスで上書きすることを警告
"narrowed-type-not-subtype", # 型ナローイング (assert isinstance 等) の不整合を検出
]
# -----------------------------------------------------------------------------
# その他の厳格化
# -----------------------------------------------------------------------------
# 到達不能コード (return 後の文、常に False の if 等) を警告。
warn_unreachable = true
# Any を返す関数を警告 (strict に含まれるが、重要なので明示)。
warn_return_any = true
# `# type: ignore` が実際に効いていない場合を警告 (strict に含まれるが明示)。
warn_unused_ignores = true
# デフォルト引数 None を Optional として暗黙扱いしない。
# `def f(x: int = None)` を許さず、`def f(x: int | None = None)` を強制する。
no_implicit_optional = true
# __init__.py で `from .a import X` した場合に外部から `pkg.X` で参照可能にしない。
# 公開 API は `__all__` または `from .a import X as X` で明示する運用を強制 (strict に含む)。
no_implicit_reexport = true
# -----------------------------------------------------------------------------
# レポート品質
# -----------------------------------------------------------------------------
pretty = true # カラー化・整形された出力
show_error_codes = true # エラーコード ([arg-type] 等) を表示 → ignore に使える
show_column_numbers = true # 列番号も表示 → エディタからのジャンプ精度向上
show_error_context = true # エラー箇所周辺の文脈を表示
# -----------------------------------------------------------------------------
# パフォーマンス
# -----------------------------------------------------------------------------
sqlite_cache = true # キャッシュを SQLite で管理 (大規模時に体感が変わる)
# -----------------------------------------------------------------------------
# モジュール別の例外設定
# -----------------------------------------------------------------------------
# 型情報のないサードパーティライブラリ。ここに列挙したものだけ Any 化を許可。
# 新しいライブラリを追加するときはここに明示的に書くことで「無断で Any が紛れた」状況を防ぐ。
[[tool.mypy.overrides]]
module = ["legacy_lib.*"]
ignore_missing_imports = true
# テストコードは fixture / mock の都合で Any を扱う場面が多いため、Any 関連だけ緩める。
# ただし関数の型注釈強制 (disallow_untyped_defs) は維持する。
[[tool.mypy.overrides]]
module = "tests.*"
disallow_any_explicit = false
disallow_any_decorated = false
uv は pyproject.toml を使うので、mypy の設定もここに書くと一元管理できて便利です。
■Pydantic でランタイム検証
◆なぜ Pydantic が必要か
型ヒント+mypyは実行前のチェックしかしません。
しかし外部から来るデータ(APIリクエスト、設定ファイル、DBレコード、JSON)は実行時に来るので、ランタイムでの検証が必要です。
def handle_request(data: dict) -> int:
return data["age"]
# ↑ mypy は data の中身を知らない
# 実行時に "age" が無かったり、文字列だったりするかも
◆mypy / 型注釈との違い
- 型注釈
- いつ動く:何もしない
- 検査対象:—
- 失敗したら:—
- 外部入力に対して:無力
- 自動変換:しない
- mypy
- いつ動く:コード実行前
- 検査対象:ソースコード
- 失敗したら:エディタ/CIで警告
- 外部入力に対して:無力(コードしか見ない)
- 自動変換:しない
- Pydantic
- いつ動く:コード実行中
- 検査対象:実際のデータ
- 失敗したら:ValidationErrorで例外発生
- 外部入力に対して:守れる
- 自動変換:する ("30" → 30 等)
def process(user: dict[str, int]) -> None:
print(user["age"] + 1)
# mypy: 「process() の呼び出し側で dict[str, int] を渡しているか」を静的に確認
# → コード上は OK
# でも実行時: 外部APIから {"age": "twenty"} が来たら何も止められない
import json, requests
process(json.loads(requests.get(...).text)) # ← 💥 実行時に TypeError
mypy は「書いたコード同士の型整合」を見るが、外から飛んでくる値は検査範囲外。
そこを埋めるのが Pydantic:
class UserPayload(BaseModel):
age: int
payload = UserPayload.model_validate(json.loads(response))
# ここで型が違えば即 ValidationError → アプリ内には型が保証された値しか入らない
process(payload) # 以降は mypy + 型注釈の世界で安全に扱える
- mypy / 型注釈: コードの内側の整合性を、実行前に守る
- Pydantic: コードの境界(外部入力)で実行時に守る
- 両者は競合せず、組み合わせて使う(Pydantic のクラスは mypy
にもちゃんと型として認識される)
◆Django Ninja との違い
- Pydantic
- カテゴリ:データ検証ライブラリ
- 役割:値の型検証・変換・JSON 化
- 比較相手:dataclasses, marshmallow,attrs
- Django Ninja
- カテゴリ:Web API フレームワーク
- 役割:HTTP、ルーティング・リクエスト処理・OpenAPI生成
- 比較相手:FastAPI, Django REST Framework, Flask
# Django Ninja のコード例
from ninja import NinjaAPI, Schema # ← Schema は実は Pydantic BaseModel
class UserIn(Schema): # ← Pydantic そのもの
name: str
age: int
api = NinjaAPI()
@api.post("/users")
def create_user(request, payload: UserIn):
# payload は Pydantic で検証済み
return {"id": 1, "name": payload.name}
Django Ninja は内部で Pydantic を使っています。
- Pydantic = 「型から検証ロジックを生成するエンジン」
- Django Ninja = 「Django に Pydantic を組み込んで、API 開発を楽にしたフレームワーク」
◆実際に試してみる
●基本: BaseModel を継承
from datetime import datetime
from pydantic import BaseModel, Field, ValidationError
# ============================================
# 1. 基本: BaseModel を継承するだけ
# ============================================
class User(BaseModel):
id: int
name: str
email: str
age: int = Field(ge=0, le=150) # 0以上150以下
# 正しいデータ
user = User(id=1, name="Junya", email="j@example.com", age=28)
print(f"OK: {user}")
# Pydanticは自動で型変換する(int変換可能な文字列など)
user2 = User(id="42", name="Alice", email="a@example.com", age="30")
print(f"OK (型変換): id={user2.id} (type={type(user2.id).__name__}), age={user2.age}")
pydanticのBaseModelを継承したUserクラスを定義して、フィールドに型を定義しています。
id="42"と文字列を指定していますが、Pydanticは自動で型変換するので問題なく実行できます。
◆バリデーションエラー
# ============================================
# 2. バリデーションエラー
# ============================================
try:
bad_user = User(id="not-a-number", name="X", email="bad", age=200)
except ValidationError as e:
# Pydanticは複数のエラーをまとめて教えてくれる
print("検出されたエラー:")
for err in e.errors():
print(f" - {err['loc']}: {err['msg']}"
複数のエラーをまとめて教えてくれるので、フォームバリデーションに最適です。
◆JSON との相互変換
# モデル → JSON
user = User(id=1, name="Junya", email="j@example.com", age=28)
json_str = user.model_dump_json()
print(f"to JSON: {json_str}")
# JSON → モデル
parsed = User.model_validate_json('{"id":2,"name":"Alice","email":"a@x.com","age":30}')
print(f"from JSON: {parsed}")
# モデル → dict
print(f"to dict: {user.model_dump()}")
モデル → JSONや、JSON → モデルへの変換もできます。
