型を付ける動機を「バグを早く見つけるため」と説明することがあります。間違いではありませんが、それだと型は検査装置どまりです。もう一歩進めた目標があります。

不正な状態を、表現できなくする(make illegal states unrepresentable)

見つけるのではなく、書けなくする。この視点で型を設計すると、実行時のチェックとテストの量がまとめて減ります。以下、実務で効く順に原則を並べます。

原則 1: プリミティブに意味を持たせない

Stringi64 をそのまま引き回すと、意味の違う値が同じ型になります。

fn transfer(from: String, to: String, amount: i64)

この関数は transfer(to, from, amount) と引数を逆に渡してもコンパイルが通ります。ユーザー ID とメールアドレスを取り違えても同じです。プリミティブ型は「形」しか表さず、「意味」を表さないためです。

薄いラッパー型を挟むと、取り違えがコンパイルエラーになります。

struct UserId(String);
struct Money(i64);

fn transfer(from: UserId, to: UserId, amount: Money)

TypeScript のように構造的型付けの言語では、ブランド型で同じ効果を得ます。

type UserId = string & { readonly __brand: "UserId" };

全部のプリミティブを包む必要はありません。取り違えたときに実害が出るもの——ID 系、金額、単位を持つ数値、外部から来た文字列——に絞れば十分です。

原則 2: bool の組み合わせを列挙に畳む

型設計でいちばん壊れやすいのが、独立した真偽値フラグの集まりです。

interface Order {
  isDraft: boolean;
  isPaid: boolean;
  isShipped: boolean;
  isCancelled: boolean;
}

フラグが 4 つなら、型が許す組み合わせは 16 通り。しかし業務上ありうる状態はせいぜい 5 つです。残りの「下書きなのに発送済み」「キャンセル済みなのに支払い済み」といった状態は、型が許してしまっているだけの存在しない状態です。そしてバグは決まってそこに生まれます。

直積(フラグの組み合わせ)ではなく直和(どれか 1 つ)で表します。

type Order =
  | { status: "draft" }
  | { status: "paid"; paidAt: Date; transactionId: TransactionId }
  | { status: "shipped"; paidAt: Date; trackingNumber: TrackingNumber }
  | { status: "cancelled"; cancelledAt: Date; reason: string };

こう書くと、状態ごとにその状態でしか存在しないデータを内側に閉じ込められます。draft の注文に trackingNumber を読もうとするコードは型エラーです。フラグ版では order.trackingNumber! のような非 null 表明が至るところに散らばっていたはずです。

判断基準は単純で、フラグ同士に「両立しない組み合わせ」があるなら列挙に畳む。独立していて全組み合わせが有効なら、フラグのままで構いません。

原則 3: 検証ではなく変換する

入力の妥当性チェックには 2 つの書き方があります。

# 検証: 通っても型は変わらない
def validate_email(s: str) -> None: ...

# 変換: 通ったら別の型になる
def parse_email(s: str) -> Email: ...

検証(validate)は、チェックした事実がコードのどこにも残りません。だから下流で「これは検証済みだったか」が分からず、念のためもう一度チェックする、という重複が生まれます。

変換(parse)は、チェックの結果が型に残ります。Email 型を受け取る関数は、値が妥当であることを前提にしてよい。この差は、チェックの記憶を人間が持つか型が持つかの差です。

実践上のポイントは 3 つです。

  • 変換はシステムの境界(HTTP ハンドラ、CLI 引数、DB 読み出し)で 1 回だけ行う
  • 変換に成功した型は、コンストラクタを閉じて外から勝手に作れないようにする
  • 変換の失敗は例外ではなく Result 相当で返し、呼び出し側に処理を強制する

原則 4: 入力・内部・出力で型を分ける

1 つの型をすべての層で使い回すと、どの層にも不要なフィールドが混ざります。よくあるのが、DB のレコード構造をそのまま API のレスポンスに使ってしまう形です。

  • 入力 DTO には id が無い(採番前だから)
  • 内部モデルには passwordHash がある
  • 出力 DTO には passwordHash があってはいけない

これを 1 つの型で兼ねようとすると、id: string | nullpasswordHash?: string が現れ、結局どの状況で何が入っているのか分からなくなります。分けたほうが、フィールドの意味も漏洩事故の防止も型で担保できます。

「同じ形の型を 3 つ書くのは重複では」という反論はもっともです。ただしこの 3 つは別々の理由で変化します。API の互換性の都合、DB のスキーマの都合、UI の都合はそれぞれ独立しているので、まとめておくと片方の変更がもう片方に波及します。形が同じなのは偶然です。

原則 5: 型を狭く受け取り、狭く返す

引数の型は必要最小限の広さにします。ユーザー全体を受け取る関数が実際は ID しか使っていないなら、UserId を受け取るべきです。依存が減り、テストも書きやすくなります。

戻り値は逆に、必要以上に広い型を返さないanyObjectMap<String, Object> を返すと、呼び出し側でキャストが必要になり、そこで型の保証が切れます。JSON を扱うコードでこれが起きやすいので、境界で具体的な型へ変換します(原則 3 と同じ話です)。

やりすぎの見極め

型で表現できることには際限がなく、型パズルに時間を溶かすこともできます。線を引く目安を挙げます。

状況 判断
実際に起きたバグ・起きうるバグを型で防げる やる
状態の組み合わせが増え続けている やる(列挙に畳む)
型を読むのに型定義を 3 ファイル追わないと分からない 行き過ぎ
ジェネリクスの制約が本体のロジックより長い 行き過ぎ
表現力が足りず asunwrap が増えている 設計が現実と合っていない

最後の行が重要です。キャストや非 null 表明が増えているときは、型が厳しすぎるのではなく、モデルが現実の状態を捉えきれていないことが多いです。逃げ道を足す前に、状態の切り方を疑います。

まとめ

  • 型設計の目的は検査ではなく、不正な状態を表現できなくすること
  • 取り違えると実害が出るプリミティブは専用型で包む
  • 両立しないフラグの組み合わせは列挙に畳み、状態ごとのデータを内側に閉じる
  • 検証(validate)ではなく変換(parse)で、チェック済みの事実を型に残す
  • 入力・内部・出力は変化する理由が違うので型を分ける
  • 引数は狭く受け取り、戻り値に広すぎる型を使わない
  • キャストや非 null 表明が増えたら、型ではなくモデルを疑う