🕹️

PythonのDTOをstrからEnumへ移行して気づいた「静的型 vs 動的型」の落とし穴

に公開

1. はじめに

PythonでDTOのstrEnumに移行するにあたりつまずいたポイントをふりかえります。

APIのパラメーターのDTOについて、もともとstrで持っていたフィールドを型安全性を目的にEnumに差し替えました。
設計思想としては「境界で文字列を受け取り、中ではEnumで扱う」という方針でしたが、移行にあたる修正範囲に考慮不足があり、バグを仕込んでしまいました。

この記事ではその失敗を振り返りつつ、動的型言語で設計を移行するときに必要な「補強策」 を共有することです。

2. 背景

実装方針は以下の通りでした。

  • Controller:APIから受け取ったstrEnum化してDTOに詰める
  • Repository:DTOを受け取り、.valueに戻してSQLにバインド

「境界=文字列/内部=Enum」という責務分担を意識していました。
ただ、移行にあたる取りこぼしがあり、バグが生まれました。

3. 静的型言語ならどうなっていたか

  • Java / C# などコンパイル型言語の場合
    • Enum化すれば未修正箇所はコンパイルエラーで検出できる
    • JSONシリアライズの型不整合もIDEや型チェックで即座に警告

型システムが安全網になり、「修正漏れ」が防げたと考えています。
※ただし、境界が stringly-typed(JSONが文字列のまま)だと静的でも落ちる

4. Pythonで漏れた現実

発生した不具合:生文字列比較が残り、常にFalseになる

from enum import Enum

class Status(Enum):
    OPEN = "open"
    CLOSED = "closed"

class DTO:
    def __init__(self, status: Status) -> None:
        self.status = status

dto = DTO(Status.OPEN)

# ❌ 移行漏れ:Enum と str の比較は常に False
if dto.status == "open":
    print("開いている")  # 実行されない

# ✅ 正:Enum 同士で比較(推奨は is。== でも可)
if dto.status is Status.OPEN:
    print("開いている")

なぜ漏れたのか?

  • Pythonは実行して初めてコード上の不整合が分かる
  • IDE補完や型ヒントは任意で、強制力がない

5. 静的型と動的型のギャップ

  • 静的型付け言語
    • コンパイル時に未修正を網羅的に検出できる
    • 安全網が強いが柔軟性は低め
  • 動的型付け言語
    • 柔軟だが、修正漏れを検出する仕組みは自前で用意する必要がある
    • 設計方針だけでは性善説に依存しがちなので補強策は不可欠

6. 動的型で補う方法(運用ルール込み)

  • 型チェック:mypy / pyright を --strict で CI ゲート
  • Lint / Grep== "open" などの生リテラル比較を禁止・検知(semgrep / grep / ruff など)
  • Repositoryで一元化.value による文字列化は Repository の関数だけで行う。比較は常に Enum 同士
    • .name は業務ロジックで使わない(使うなら用途限定&明示変換)
  • テスト強化:変更影響箇所は網羅的にテスト

7. 学び

  • 設計自体は健全だった(境界=文字列/内部=Enum
  • 動的型付け言語への理解が甘く、認識漏れがバグに直結した
  • 移行の補強策が必要だった
  • 動的型言語では「設計+検出+観測」の三本立てが必要
  • 静的型での“当たり前の安全網”は Python では自動では得られない

8. まとめ

  • 静的型→動的型では、「壊れるときの守り方」がまるで違う
  • 設計パターンをなぞるだけでは足りない
  • 安全網をどう補うかを設計と同列に考えて、静的型に近い安心感へ寄せる

9. おわりに

自分の失敗をもとに学んだことを整理しました。
同じように静的型→動的型で違和感を持つ人へのヒントになれば幸いです。
特に私はJavaで長年育ったため、改めて静的型付け言語と動的型付け言語の差異を意識する必要を感じました。

Discussion