型を付ける動機を「バグを早く見つけるため」と説明することがあります。間違いではありませんが、それだと型は検査装置どまりです。もう一歩進めた目標があります。
不正な状態を、表現できなくする(make illegal states unrepresentable)
見つけるのではなく、書けなくする。この視点で型を設計すると、実行時のチェックとテストの量がまとめて減ります。以下、実務で効く順に原則を並べます。
原則 1: プリミティブに意味を持たせない
String や i64 をそのまま引き回すと、意味の違う値が同じ型になります。
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 | null と passwordHash?: string が現れ、結局どの状況で何が入っているのか分からなくなります。分けたほうが、フィールドの意味も漏洩事故の防止も型で担保できます。
「同じ形の型を 3 つ書くのは重複では」という反論はもっともです。ただしこの 3 つは別々の理由で変化します。API の互換性の都合、DB のスキーマの都合、UI の都合はそれぞれ独立しているので、まとめておくと片方の変更がもう片方に波及します。形が同じなのは偶然です。
原則 5: 型を狭く受け取り、狭く返す
引数の型は必要最小限の広さにします。ユーザー全体を受け取る関数が実際は ID しか使っていないなら、UserId を受け取るべきです。依存が減り、テストも書きやすくなります。
戻り値は逆に、必要以上に広い型を返さない。any、Object、Map<String, Object> を返すと、呼び出し側でキャストが必要になり、そこで型の保証が切れます。JSON を扱うコードでこれが起きやすいので、境界で具体的な型へ変換します(原則 3 と同じ話です)。
やりすぎの見極め
型で表現できることには際限がなく、型パズルに時間を溶かすこともできます。線を引く目安を挙げます。
| 状況 | 判断 |
|---|---|
| 実際に起きたバグ・起きうるバグを型で防げる | やる |
| 状態の組み合わせが増え続けている | やる(列挙に畳む) |
| 型を読むのに型定義を 3 ファイル追わないと分からない | 行き過ぎ |
| ジェネリクスの制約が本体のロジックより長い | 行き過ぎ |
表現力が足りず as や unwrap が増えている |
設計が現実と合っていない |
最後の行が重要です。キャストや非 null 表明が増えているときは、型が厳しすぎるのではなく、モデルが現実の状態を捉えきれていないことが多いです。逃げ道を足す前に、状態の切り方を疑います。
まとめ
- 型設計の目的は検査ではなく、不正な状態を表現できなくすること
- 取り違えると実害が出るプリミティブは専用型で包む
- 両立しないフラグの組み合わせは列挙に畳み、状態ごとのデータを内側に閉じる
- 検証(validate)ではなく変換(parse)で、チェック済みの事実を型に残す
- 入力・内部・出力は変化する理由が違うので型を分ける
- 引数は狭く受け取り、戻り値に広すぎる型を使わない
- キャストや非 null 表明が増えたら、型ではなくモデルを疑う