0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Pythonの型システム

0
Posted at

■はじめに

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 | Noneint | 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の静的解析ツールにかけてみます。

image.png

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 → モデルへの変換もできます。

■資料

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?