Share
型は壁、Rustでもバグを直すな、表現できなくせよ
**「バグを直す」のはもうやめませんか。**
あなたのシステムにもいませんか。`is_paid = true` なのに `payment_id` が null という、支払ったのか払ってないのかシュレディンガーの注文。`status = "verified"` なのに `verified_at` が存在しない、検証された記憶のない検証済みユーザー。彼らはバグではありません。**型が「そういう存在」を許可してしまった、正規の住人**です。
本セッションの主張はシンプルです。型は壁である。コンパイラのご機嫌取りでも、IDEの補完を効かせるための飾りでもなく、「なり得ない状態」を物理的にこの世に存在させないための壁。バグはテストで捕まえるものではなく、**そもそも書けなくする**ものです。
実装言語はRustです。「関数型まつりでRust?」と思ったあなた、安心してください。代数的データ型、イミュータビリティ、パターンマッチ——中身はほぼ関数型の道具箱です。Rust未経験でも大丈夫なように、登場する言語仕様はその場で解説します。扱うのは以下の4パターン:
- 状態ごとに型を分ける: 検証前の注文で価格計算しようとした瞬間、コンパイラに止められる世界
- newtypeパターン: `CustomerId` と `OrderId` の取り違えを、レビュアーの目ではなくコンパイラが検出する
- Smart Constructor: 「※必ずvalidateしてから使ってください」というお祈りコメントの卒業
- Make Illegal States Unrepresentable: フラグ×Optionの組み合わせ爆発を、代数的データ型で根絶する
おまけに、AIコーディングエージェント時代の話も。AIはコメントを平気で読み飛ばしますが、コンパイルエラーだけは無視できません。**型は「お願い」ではなく「壁」。** 人間にもAIにも等しく効く、最強の防壁の築き方を持ち帰ってください。
DDDの知識は不要です。必要なのは「もうバグ調査で週末を潰したくない」という気持ちだけ。
nwiizo
Transcript
80 slides
-
型は壁、Rust でもバグを 型は壁、Rust でもバグを 直すな、表現できなくせよ 直すな、表現できなくせよ is_paid = true is_paid
= true なのに なのに payment_id payment_id が null 、という話 が null 、という話 関数型まつり2026 公募セッション @nwiizo 50min -
今日お話しすること 1. なぜ型を「壁」にするのか 2. 関数型の考えをRust で使う 3. 不正な値を作らせない4 つのパターン 4.
Rust の所有権と型状態を使う 5. 境界と既存コードへ段階導入する 6. まとめ 関数型まつりの参加者には当たり前の内容も含まれます。直和型や Option / Result は短く確認し、Rust で使うと何が 起きるかに時間を使います。 Rust が初めての方へ:記号は登場時に説明します。コードは、細部より「何を受け取り、何を返すか」を追ってください。 4 -
壁をどこに建てるか あらゆる設計原則は「変更を容易にする」の派生である、という補助線で型の壁を評価します。 設計原則 変更を容易にする仕組み カプセル化 変わりうる詳細を境界の内側へ閉じ込める 疎結合 変更が無関係な部品へ伝播するのを止める DRY /
Single Source of Truth 同じ判断を直す場所を1 か所に寄せる テスト / 型 変更が壊した前提を、利用者より先に検出する 型も目的ではありません。不正な状態を閉じ、次の変更で失う時間と確信を減らすための手段です。 主題は型の壁。変更容易性は、壁の置き場所を決める補助線 6 -
疎結合は、設計のゴールではない 結合とは、コンポーネント同士が接続され、知識やライフサイクルを共有することです。結合がなければ、部品は協調で きず、システムになりません。 結合を弱めすぎる 本来一緒に変わるルールが別々の場所へ散り、整合性の 維持と調整が難しくなる。 結合を強めすぎる 無関係な変更まで伝播し、1 つの修正に多くのコンポー ネントが巻き込まれる。
問うべきは「結合しているか」ではなく、一緒に変わる知識が、変更しやすい場所に結合されているかです。 参考: Vlad Khononov, Balancing Coupling in Software Design, Ch.1 7 -
変更コストは、結合の3 次元で考える 強度 境界を越えて 共有する知識の量 距離 変更を調整する コード・チームの隔たり 変動性 その知識が
変わる頻度 予想保守労力 ≈ 強度 × 距離 × 変動性 これは精密な測定式ではなく、どこを動かせば変更コストが下がるかを見るモデルです。3 つすべてが高い関係を優先し て直します。 参考: Vlad Khononov, Balancing Coupling in Software Design, Ch.10 8 -
距離が大きいほど、変更の調整コストは増える 同じ変更でも、1 つの関数内で完結する場合と、別 チームのサービスまで同時に直す場合ではコストが 違います。 距離には、コード上の隔たりだけでなく、リポジト リ、デプロイ単位、担当チーム、タイムゾーンも含 まれます。 距離を広げるなら、境界を越える共有知識を減らす 必要があります。
出典: Vlad Khononov, Balancing Coupling in Software Design, Figure 8.2 9 -
共有知識と距離の釣り合い 共有知識と距離が反対方向なら、設計はモジュール性へ向かいます。 知識が多いなら、近くに置いて高凝集にする 遠くへ離すなら、公開する知識を小さくする 両方が大きいと変更が遠くへ波及し、両方が小さいと無関係なものが同 居して探索コストが増えます。 近くでは強く、遠くでは小さな契約だけを共有する 出典: Vlad Khononov,
Balancing Coupling in Software Design, Figure 14.1 10 -
型は、結合を消さず、置き直す bool + Option is_paid と payment_id の関係を、すべての利用者 が暗黙に知る。 共有知識がコード全体へ漏れ、変更時に遠くの
if を探 す。 enum PaymentState 2 つの値の関係を、型定義と生成境界へ集める。 利用者には可能な状態だけを公開し、変更箇所は型エラ ーで見つける。 型は結合を弱める魔法ではありません。一緒に変わる知識を型の内側で強く結び、不要な組み合わせを境界の外へ漏らさ ない道具です。 型の壁は、結合の置き場所を変える 11 -
3 年運用したDB には、こんなレコードが眠っている 新規のシステムなら、こんなデータは絶対に生まれないと思うかもしれません。でも、3 年運用したデータベースには、 こういう矛盾したレコードが眠っていることがあります。 { "order_id": 12345, "is_paid":
true, "payment_id": null } このレコードは、単体の値だけを見るとそれらしく見えます。 is_paid も payment_id も、それぞれの型としては正 しい。 13 -
矛盾はあとから効いてくる 問題は、値を組み合わせた瞬間に起きます。 is_paid = true なのに payment_id が null どの決済で完了したかの記録がなく、返金も、会計との
突き合わせもできない。 status = "verified" なのに verified_at が null 再認証ポリシーは比較の基準日を持てず、落ちるか素通 りするかの二択になる。 こうしたレコードを作った瞬間にアラートが鳴ったりはしません。静かにDB に残り、数ヶ月後に返金業務や再認証処理 で突然例外を投げる。 単独では正しい値が、組み合わせると矛盾する 14 -
なぜ、型はこれを止められなかったのか 先ほどのレコードを受け取る型は、もしかしたらこう書かれていたかもしれません。 pub struct Order { pub is_paid: bool, pub
payment_id: Option<PaymentId>, } この型は、 bool と Option<PaymentId> の任意の組み合わせを許します。 「正しい組み合わせ」と「あり得ない 組み合わせ」を区別する情報は、型のどこにも書かれていません。 つまり、型が「正しい形」を規定していない。正しさはコメントやバリデーション関数の中に散らばり、どこかで抜 け落ちる。抜け落ちた瞬間、矛盾レコードが静かに誕生します。 15 -
AI Slop 問題は、コードにもやってくる AI Slop は、AI によって作られる低品質なデジタルコンテンツを指す言葉です。ソフトウェア開発でも、生成コードだけ でなく、PR ・文書・バグ報告にまで及ぶと報告されています。 生成する側
短時間で、もっともらしいコードを大量に作れる。 レビューする側 ドメインの制約や文脈を、1 件ずつ検証する必要があ る。 生成量が増えるほど、コメントに書いた制約は有限のレビュー予算を奪い合います。生成速度にレビューが追いつかなけ れば、人間は疲弊し、問題が見落とされたまま低品質なソフトウェアがリリースされます。 生成コストは下がる。検証コストは消えない 出典: Baltes, Cheong, Treude (2026) “An Endless Stream of AI Slop” 17 -
AI 時代、コメントより型は破りにくい では、AI Slop に埋もれない制約はどう作るのか。人間向けの自然言語では、こう書かれます。 // payment_id は is_paid=true のときだけ
Some、それ以外は None にすること pub struct Order { pub is_paid: bool, pub payment_id: Option<PaymentId>, } コメントは条件付きのお願いです。 「できればこうしてください」と書いても、人間もAI コーディングエージェントも読み 落とすことがあります。 型は機械が検査する契約です。 「この形でなければビルドを通さない」という制約は、 cargo build が検査します。 AI はコードを生成できますが、型エラーのまま動く実行ファイルは生成できません。だから AI 時代こそ、自然言語の「お 願い」を、コンパイラが検査できる「壁」へ移します。 型は、お願いをビルドエラーに変える 18 -
壁は、設計図ごと壊せる 型は強い壁ですが、無敵ではありません。壁を迂回する経路は3 つあります。 unsafe / unchecked API :コンパイラが証明できない安全条件を人間が引き受ける 公開フィールドや未検証の deserialize
:検証付き生成関数を通らない生成経路を残す 型定義そのものを緩めるPR : NonZero を u32 に戻す、enum に Option を足す コメントは黙って破れますが、型の壁を迂回すると痕跡が残ります。 unsafe や unchecked API は呼び出しとし て、型定義の変更は diff として可視化される。 型は壁ですが、壁の設計図を守るのは人間のレビューです。AI が型定義を緩める PR を出したとき、気づけるのは人 間だけ。型はコードレビューを不要にはしません。 型は壁。でも、壁の設計図を守るのは人間 19 -
型は、値の集まりに付ける名前 関数型まつりの皆さんにはおなじみですが、ここでは Rust の例で確認します。 型 = 値の集合に付けた名前( bool = {true,
false} 、 u64 = 0 〜約1800 京) 型を「値の集まり」と考えると、設計の狙いが単純になります。正しい値だけが入るように、集合を小さくするのです。 たとえば「0 ではない整数」という型を作れば、0 はその集合に入りません。0 を見つけて弾くのではなく、最初から表現 できなくします。 型を狭くすると、バグの置き場所も狭くなる 21 -
AND 型は掛け算、OR 型は足し算 データの形は、全部を持つか、どれか1 つを選ぶかで考えます。 AND 型(struct ) フィールドが全部同時に存在する。 struct
User { name: String, age: u32, } OR 型(enum ) いくつかの候補からちょうど1 つ。 enum Shape { Circle(f64), Square(f64), } Option (あるか、ないか)と Result (成功か、失敗か)もOR 型です。AND 型は組み合わせが増えるので掛け算、OR 型は候補を並べるので足し算になります。 掛け算で増えた組み合わせに、バグが潜む 22 -
関数型の道具は、Rust にある ここでは、関数型でおなじみの道具がRust では何に当たるかだけ整理します。 関数型(F# / OCaml / Scala など)
Rust Record / Sum type (Choice, DU ) struct / enum Maybe / Either Option<T> / Result<T, E> Pattern matching (網羅性チェック) match (網羅をコンパイラが強制) Single case DU + Smart constructor タプル構造体 + pub fn new() -> Result struct 、 enum 、網羅的なパターンマッチが揃っているので、関数型のドメインモデリングはほぼそのままRust で 書けます。ここからは、Rust 固有の所有権も組み合わせます。 Rust 豆知識(Programming Rust 3rd Early Release, Ch.10 ) Rust の enum は、ML 系言語の直和型に相当します。Rust では、そこへ参照・可変性・メモリ安全性を検査する借用チェッカ ーも組み合わさります。 23 -
Rust の記号は、ここだけ読めればいい 本編で頻繁に登場する記号だけ先に整理します。これでコード例は雰囲気で読めます。 参照と所有権 &T :読み取り専用で借りる &mut T :書き換え可能な状態で借りる 参照なし(
T ) :所有権ごと渡す。呼び出し元では使 えなくなる エラー処理 Result<T, E> :成功 Ok(T) か失敗 Err(E) ? 演算子:失敗ならその場で返し、成功なら中身を 取り出す Option<T> :値がある Some か、ない None 細かい記号は登場時に補足します。構文に悩んだら「型が何を受けて何を返すか」だけ見てください。 24 -
? は、失敗した時点で処理を返す ? は、処理に失敗したらその場で呼び出し元へ返し、成功したら中身を取り出します。 fn parse_user_id(s: &str) -> Result<UserId, ParseError>
{ // (1) 文字列を数字にパース。失敗すれば Err で早期リターン let n: u64 = s.parse()?; // (2) UserId を作る。0 は不正なので失敗しうる let id = UserId::new(n)?; // (3) 成功したら Ok で包んで返す Ok(id) } Option<T> と Result<T, E> は、どちらも ? でつなげられます。失敗のたびに if を書く必要はありません。 25 -
所有権は、古い状態の使い回しを止める Rust の所有権は、 「各値には所有者がいる」というルールです。家の鍵を誰かに渡すと、自分の手元には残りません。そ れと同じように、 Copy でない値を関数へ渡すと所有権が移り、呼び出し元からは使えなくなります。 この仕組みを使うと、 「検証前の注文は、検証した後に残ってはいけない」を型で表現できます。 「検証前の注文をもう1
回使う」コードが、そもそも書けません。前の状態が残らないことを、所有権ルールが型レベルで保証してくれます。 このルールが、後半で「状態遷移の壁」になります。コードはパターン1 でお見せします。 26 -
Rust の値は、標準では書き換えられない Rust では変数も参照も、デフォルトで書き換え不可です。書き換えたいときは mut を明示する必要があります。 let order = ValidatedOrder
{ items: vec![...] }; order.items.push(new_item); // ← コンパイルエラー。order は不変 let mut order = ValidatedOrder { items: vec![...] }; order.items.push(new_item); // ← これは通る この仕組みは、メソッドの設計にも影響します。関数型と同じく「入力を受けて新しい値を作る」が自然になります。 // 関数型的な書き方: 新しい値を返す fn apply_discount(o: PricedOrder, rate: f64) -> PricedOrder { /* ... */ } // OOP 的な書き方: 既存の値を書き換える impl PricedOrder { fn apply_discount(&mut self, rate: f64) { /* ... */ } } 前者は &mut self を要求せず、入力の所有権を受け取って次の値を返します。その場で書き換えるAPI ではないことが 関数の型に現れます。 27 -
パターン1 状態が違えば、型を分ける 「検証前の注文」と「検証済みの注文」を同じ型で扱うと、検証をスキップしたコードが通ります。 struct Order { validated: bool, items: Vec<Item>,
} fn calculate_total(order: &Order) -> Money { // validated == false でも呼べてしまう order.items.iter().map(|i| i.price).sum() } validated フラグは実行時の情報であり、呼び出し側が確認を忘れてもコンパイラは何も言いません。 29 -
別の型にすれば、順序違反は書けない 検証前と検証後を別の型にします。 struct UnvalidatedOrder { items: Vec<Item> } struct ValidatedOrder
{ items: Vec<Item> } fn validate(o: UnvalidatedOrder) -> Result<ValidatedOrder, OrderError> { // チェックを通った場合だけ ValidatedOrder が生まれる } fn calculate_total(o: &ValidatedOrder) -> Money { o.items.iter().map(|i| i.price).sum() } calculate_total(&unvalidated) はコンパイルエラーになります。 「検証前の注文を価格計算に渡す」バグは、そも そも書けません。 30 -
ワークフロー全体を型で貫く 状態ごとに型を分ける発想を注文処理の一連の流れに適用すると、こうなります。 struct UnvalidatedOrder { items: Vec<RawItem> } struct ValidatedOrder
{ items: Vec<Item> } struct PricedOrder { items: Vec<Item>, subtotal: Money } struct PaidOrder { items: Vec<Item>, subtotal: Money, payment: PaymentId } fn validate(o: UnvalidatedOrder) -> Result<ValidatedOrder, OrderError>; fn price(o: ValidatedOrder) -> PricedOrder; fn charge(o: PricedOrder, card: &Card) -> Result<PaidOrder, PaymentError>; 各ステップの入出力が型で固定されているので、順序を間違えるコードは書けません。検証前の注文に価格をつけるコー ドも、支払い前の注文を確定するコードも、コンパイルが通りません。 関数の入出力の型が、ワークフローの仕様書になる 31 -
パターン2 同じ数字でも、意味ごとに型を分ける 状態型(パターン1 )とその応用はここまで。残り3 つのパターンに進みます。まずは取り違えの防止。 顧客ID と注文ID 、どちらも u64 で扱うと、引数の順序ミスがすり抜けます。
fn charge(customer_id: u64, order_id: u64) { /* ... */ } let customer = 1001u64; let order = 5678u64; charge(order, customer); // ← 引数逆でもコンパイルが通る これは実行時に初めて気づくバグです。ユニットテストを全網羅しない限り、本番で発覚します。 32 -
newtype は、値を1 つだけ包む Rust 本でも紹介されているニュータイプパターンです。タプル構造体で1 フィールドだけ包みます。 #[repr(transparent)] struct CustomerId(u64); #[repr(transparent)]
struct OrderId(u64); fn charge(customer: CustomerId, order: OrderId) { /* ... */ } let customer = CustomerId(1001); let order = OrderId(5678); charge(order, customer); // ← 型エラー、コンパイルが通らない #[repr(transparent)] を付ければ u64 と同じレイアウトが保証され、取り違えはコンパイラが検出してくれます。 Rust 豆知識(Programming Rust 3rd Early Release, Ch.9 ) 書籍では、コメントで意味を区別する代わりにニュータイプを使い、Rust の型検査へ任せる方法が紹介されています。意味が違う値 を、同じ基本型へ戻さないのがポイントです。 同じ u64 に別の意味を持たせると、必ずどこかで混ざる 33 -
パターン3 作り方を限定して、不正値を作らせない この方法は検証付き生成関数(Smart Constructor )と呼ばれます。 「メールアドレスには @ が必要」という条件を、値 を作るときに必ず検査します。 pub
struct Email(String); impl Email { pub fn new(s: &str) -> Result<Self, EmailError> { if !s.contains('@') { return Err(EmailError::Invalid); } Ok(Email(s.to_owned())) } pub fn as_str(&self) -> &str { &self.0 } } 中身のフィールドは非公開なので、外部コードは Email::new を通らずに Email 値を作れません。この例では、一度 作れた Email は以降のコードで必ず @ を含みます。 34 -
検証した印を、型に残す 検証付き生成関数の考え方を、Alexis King は「Parse, don't validate 」と表現しました。 "Parse, don't validate."
検証するな。解釈せよ。 validate は bool を返すだけ。通った値も通らなかった値も、同じ String のまま旅を続けます。parse は別の型に変 換し、 「検証済みである」情報が型に刻まれます。下流のコードは、もう検証を気にしません。 fn validate(s: &str) -> bool; // 情報は型に残らない fn parse(s: &str) -> Result<Email, _>; // 情報が型に刻まれる 検証済みの証明を、値そのものに運ばせる Alexis King (2019) "Parse, don't validate" (lexi-lambda.github.io/blog/2019/11/05/parse-don-t-validate/ ) 35 -
この型、4 通りのうち何通りがバグ? 1 つの値は、検証付き生成関数で守れました。次は値の組み合わせです。 「認証済みなら認証日時がある」をstruct で書く と、何通りの状態が生まれるでしょうか。 struct User {
email: String, is_verified: bool, verified_at: Option<DateTime<Utc>>, } この型が表現できる状態は、4 通りです。 is_verified verified_at 意味 false None 未認証(正しい) true Some(t) 認証済み(正しい) true None 不正:認証済みなのに日時がない false Some(t) 不正:未認証なのに日時がある 36 -
型の半分が、バグの置き場所になる 4 通りのうち、業務上正しいのは2 通りだけです。 型が許す状態 bool 2 通り × Option
2 通り = 4 通り 業務で正しい状態 未認証 + 認証済み = 2 通り AND 型は、フィールドの選択肢を掛け算します。フラグを足すたびに状態は増えますが、増えた状態が正しいと は限りません。 正しい状態が2 つなら、型にも2 つだけ書く 37 -
正しい状態だけをenum に書く フラグと日時を別々に持たず、 「未認証」か「認証済み」のどちらか一方を enum で表現します。 enum User { Unverified
{ email: String }, Verified { email: String, verified_at: DateTime<Utc> }, } Verified なら verified_at は必ず存在します。 Unverified には、そのフィールド自体がありません。 4 通りを許してから2 通りを弾くのではなく、正しい2 通りだけを最初から型に書きます。 38 -
match は、状態の書き忘れも防ぐ enum を使うコードは、 match で状態ごとの処理を書きます。 fn send_receipt(user: &User) {
match user { User::Verified { email, verified_at } => { /* 送る */ } User::Unverified { .. } => { /* 送らない */ } } } どちらかの状態を書き忘れると、コンパイラがエラーにします。新しい状態を追加したときも、修正が必要な match がすべて分かります。 不正な状態を表現不可能にする 39 -
4 つの基本パターン、一覧で確認 ここまで駆け足で見てきた4 パターンを、一枚にまとめます。 1. 状態ごとに型を分ける UnvalidatedOrder と ValidatedOrder は別の型。検証
前の注文を価格計算に渡すコードは、書けない。 2. 意味ごとにnewtype を作る CustomerId と OrderId を別の型に。引数の順序を間違 えればコンパイルが通らない。 3. 検証付き生成関数を通す 作り方を限定すれば、不正な Email は存在できない。検証 済みという情報が型に残る。 4. 正しい組み合わせだけをenum にする フラグと Option を別々に持たず、正しい状態だけを候補と して並べる。 この4 つだけでも、 『is_paid と payment_id が矛盾する』系のバグは書きようがなくなります。 40 -
出力は、依存と逆向きに知識を流す 下流の Module A は上流の Module B に依存します。一方、型・variant ・フィールド・エラーという知識は、B の出力から
A へ流れます。 出力型を豊かにするほど、A は高度な判断ができます。同時に、B がその型を変えたとき、A も一緒に変わる可能性が高く なります。 出力型は、下流へ公開する知識の境界 出典: Vlad Khononov, Balancing Coupling in Software Design, Figure 10.1 41 -
出力が豊かほど、下流の判断力と結合が増える ドメイン型をそのまま返す fn payment_state() -> PaymentState; 下流は全状態を型安全に扱える。一方、variant やフィー ルドは公開した知識になり、変更が利用者へ伝播する。 用途別の出力へ絞る
fn receipt_status() -> ReceiptStatus; 利用者に必要な知識だけを公開できる。一方、別の判断 が必要になるたび、出力やAPI を追加する必要がある。 同じモジュール内で共に進化するなら豊かな型が効きます。遠い利用者へ返すなら、利用目的に合わせた小さなコントラ クトの方が変更を閉じ込めます。 出力の情報量は、利用者の力と提供者の変更自由度を交換する 42 -
enum は、網羅性と拡張性を交換する 閉じた enum pub enum PaymentState { Unpaid, Paid(PaymentId),
} 利用者は網羅的に match できる。新しいvariant の追加 は、利用者の修正を要求する破壊的変更になる。 拡張を許す enum #[non_exhaustive] pub enum PaymentState { Unpaid, Paid(PaymentId), } 提供者はvariant を追加しやすい。利用者はワイルドカー ドが必須になり、未知の状態を個別には扱えない。 同時に更新できる内部コードでは閉じた enum 、独立して更新されるライブラリ境界では #[non_exhaustive] が候補 になります。 43 -
エラー出力は、回復可能性と変更自由度を交換する 型付きエラー fn charge(...) -> Result<Receipt, ChargeError>; 呼び出し側は残高不足だけ再試行し、カード拒否は利用 者へ返せる。variant は回復契約になり、変更しにくい。
不透明なエラー fn run_job(...) -> anyhow::Result<()>; 内部エラーを包みやすく、実装を変えやすい。呼び出し 側は型による分岐ができず、記録して失敗させる程度に なる。 利用者に回復行動を選ばせるAPI では型付き、最上位で記録して終了する処理では不透明なエラーが自然です。 利用者が分岐すべき失敗だけを、公開エラー型にする 44 -
借用して返すか、所有して返すか &str 割り当て不要 元の値より長く保持できない String 独立して保持できる 複製・割り当てコストを持つ Arc<str> 安価に共有できる 参照カウントとAPI
複雑性を持つ fn name(&self) -> &str; // 呼び出し側を self の寿命に結合 fn into_name(self) -> String; // self を消費し、独立した値を返す 出力の所有権はパフォーマンスだけでなく、呼び出し側が値をいつまで、どこへ運べるかを決めます。コピーを避ける代 わりに寿命を結合するのか、所有権を渡して距離を切るのかを選びます。 45 -
状態を型パラメータに乗せる 先ほどは UnvalidatedOrder と ValidatedOrder を別々の構造体にしました。中身がほぼ同じなら、注文は1 つ のまま、状態だけを型で切り替えられます。 型状態(Type State
)パターン Order<Unvalidated> と Order<Validated> は、中身が同じでも別の型です。検証が終わるまで、検証済み の注文だけに許した操作は呼べません。 別々の構造体を何度も書かずに、状態遷移を型で表せるのが利点です。 47 -
PhantomData は、実体を増やさず状態を載せる Order<State> と書いても、注文のフィールドには State の値がありません。そこで PhantomData を使い、こ の型はState に依存するとコンパイラへ伝えます。
use std::marker::PhantomData; struct Order<State> { items: Vec<Item>, _state: PhantomData<State>, } PhantomData<State> は実行時には0 バイトです。データを増やさず、型の上だけに状態を追加します。 48 -
型状態パターンのコード例 use std::marker::PhantomData; struct Unvalidated; struct Validated; struct Order<State> {
items: Vec<Item>, _state: PhantomData<State> } impl Order<Unvalidated> { fn validate(self) -> Result<Order<Validated>, OrderError> { Ok(Order { items: self.items, _state: PhantomData }) // 検証は省略 } } impl Order<Validated> { fn total(&self) -> Money { // Validated にだけ実装 self.items.iter().map(|i| i.price).sum() } } total() は Order<Validated> にしか実装されていないので、 Order<Unvalidated> に対しては呼べません。状態 遷移は self 消費で表現され、 Unvalidated の注文は検証後には残りません。 49 -
自作する前に、既製の制約型を探す ここまでの壁は全部自作でした。でも、自作する前にすでに用意されている制約型で済まないかを確認するのが先です。 標準ライブラリとエコシステムに、制約が型に埋め込まれた型はいくつも揃っています。 標準ライブラリ NonZero<T> :ゼロではない整数型 ( NonZero<u32> 、 NonZeroU32
は別名) Option<&T> :参照自体は非null 。値がない場合は None String / &str :UTF-8 を保証 エコシステム nonempty : NonEmpty<T> で空でないVec を保証 nutype :マクロでnewtype と検証処理をまとめて生 成 bounded-integer :範囲を型に持つ整数newtype を 生成 n: u32 ではなく n: NonZero<u32> 、 items: Vec<Item> ではなく items: NonEmpty<Item> と書けば、 「ゼロ・ 空を渡してはいけない」が型に残り、生成後のAPI では再確認が不要になります。 50 -
フィールドを足すとOption の海に戻る 要件が増えたとき、つい既存の型にフィールドを足したくなります。でもそれは、組み合わせ爆発とOption の海に戻る道 です。 // Before: ValidatedOrder に配送料の情報を足したい struct
ValidatedOrder { items: Vec<Item>, shipping_cost: Option<Money>, // ← 計算前は None shipping_address: Option<Address>, // ← 計算前は None } 配送料が計算済みかどうか、配送先が確定しているかどうかが、また Option の組み合わせに散らばります。 51 -
型エラーを、変更地図にする 新しい状態は、新しい型にする。配送情報が必要になったら、それを必須フィールドに持つ型を作ります。 struct PricedOrder { items: Vec<Item>, subtotal: Money }
struct PricedOrderWithShipping { // 配送情報は必須・Optionにしない items: Vec<Item>, subtotal: Money, shipping: ShippingInfo, } 型が増えると、コンパイラが依存箇所を全部追跡します。 「直し忘れ」はビルドエラーになる。エラーは邪魔ではなく、次 に直す場所を列挙した変更地図です。 ただし、地図を細かくするほど型は増えます。変更の見落としは減りますが、読む負担は増える。ここからは、型が変更 を難しくする境界も見ていきます。 コンパイルエラーは、変更の残作業リストになる 52 -
摩擦1 古い状態を借りたまま、次へ進めない ここまで型で何でも守れるかのように話してきました。でも、実際にコードを書くと、最初につまずくのがここで す。 状態遷移を fn validate(o: UnvalidatedOrder) -> Result<ValidatedOrder,
_> の形で書くと、入力の注文 は所有権ごと消費されます。 これが摩擦になるのは、古い注文への参照を残したまま、次の状態へ進めたい場面です。 let order: UnvalidatedOrder = receive(); let audit_view = ℴ // 古い状態を借りる let valid = validate(order)?; // ← 借用中なので move できない log_for_audit(audit_view); // 借用がここまで生きている 監査ログやメトリクスへ古い値を渡したいなら、必要な情報だけコピーするか、共有したい部分を Arc に分けま す。古い状態を誰がいつまで見るのかを明示的に決める必要があります。 54 -
摩擦2 重複を消すと、match が1 段深くなる パターン4 の enum では、共通の email が両方のバリアントで繰り返されていました。Rust
には「enum 全体で共 通のフィールド」を直接書く機能はありません。対処は2 つあります。 素直に繰り返す enum User { Unverified { email: String, }, Verified { email: String, verified_at: DateTime<Utc>, }, } 状態だけを enum にする struct User { email: String, state: UserState, } enum UserState { Unverified, Verified(DateTime<Utc>), } どちらも「検証済みのときだけ verified_at がある」保証は同じです。後者は共通フィールドを1 箇所に集められ る代わりに、状態を見るたび user.state を1 段深く match します。トレードオフは保証の強さではなく、重複 と操作の近さです。 55 -
摩擦3 型状態は、読む人の負担を増やす Order<Unvalidated> と Order<Validated> で状態を分けると壁は強固になりますが、関数の型が複雑になります。 // 2状態なら、まだ読める fn process(o:
Order<Validated>) -> Result<Order<Priced>, Error>; // 3状態以上で、複数のジェネリクスが絡むと急に重くなる fn handle<S>(o: Order<S>) -> Result<Output, Error> where S: Into<FinalState>, Order<S>: Processable; 型が表す制約は強くなりますが、読み手には「この S は何か」 「どの状態へ進むのか」を追う負荷が増えます。 56 -
型状態を使う境界を決める 関数型まつりの皆さんには馴染みがあっても、チームの全員がこの関数の型を読めるとは限りません。制約を増やす ほど、読む負担と変更コストも増えます。 順序違反をコンパイル時に止めたいワークフローなら型状態パターン 実行時に任意の状態を読み込むなら enum 、状態ごとに中身が大きく違うなら別 struct 共有データが大きいなら、状態と共通データを分離して読む負担と複製コストを抑える 判断基準は「型が強いほど良い」ではありません。防げる障害と見落としのコストが、導入・学習・変更のコストを
上回る場所にだけ壁を作ります。 変更の総コストを下げない壁は、設計ではなく障害物 57 -
外から来る値には、まだドメイン型がない 型で守れるのは、業務ルールを扱うドメインの内側です。HTTP やDB から来た直後の値には、まだ専用の型が付いていま せん。 #[derive(Deserialize)] // HTTP入力を受ける境界型 struct CreateOrderRequest
{ customer_id: u64, // ← まだ CustomerId ではない items: Vec<ItemInput>, // ← まだ ValidatedItem ではない email: String, // ← まだ Email ではない } HTTP 、JSON 、DB 、メッセージキューから来る値は、最初は u64 や String です。内側へ渡す前に、 CustomerId や Email へ変換します。 58 -
境界で型に変換し、内側へ渡す fn create_order(req: CreateOrderRequest) -> Result<ValidatedOrder, ApiError> { let customer
= CustomerId::new(req.customer_id)?; let email = Email::new(&req.email)?; let items = req.items.into_iter().map(Item::try_from) .collect::<Result<Vec<_>, _>>()?; ValidatedOrder::new(customer, email, items) } 境界のただ1 か所で検証して型に変換します。以降は、型付きの値だけが内側を流れます。 境界で型を貼り、内側は型で守る 59 -
DB へ保存するとき、状態は列に戻る リレーショナルDB の行は列の組です。Rust のenum を保存するときは、 is_paid BOOLEAN と、NULL を許す
payment_id UUID のような列へ戻すことがあります。 Rust 側では決済状態を enum で受けられますが、書き込み時には再びフラグとnull に分解する必要があります。 // enumからDBの列へ変換する fn to_row(payment: &PaymentState) -> OrderRow { match payment { PaymentState::Unpaid => OrderRow { is_paid: false, payment_id: None, }, PaymentState::Paid(id) => OrderRow { is_paid: true, payment_id: Some(id.clone()), }, } } 60 -
DB の矛盾は、読み込み時にエラーにする DB から読み込むときは、列の組をもう一度ドメイン型へ変換します。 fn from_row(row: OrderRow) -> Result<PaymentState, OrderRowError>
{ match (row.is_paid, row.payment_id) { (false, None) => Ok(PaymentState::Unpaid), (true, Some(id)) => Ok(PaymentState::Paid(id)), (true, None) => Err(OrderRowError::MissingPaymentId), (false, Some(_)) => Err(OrderRowError::UnexpectedPaymentId), } } 冒頭の「3 年眠っていたレコード」と再会するのは、まさにこの (true, None) の行です。型は新しい矛盾を防ぎ、すで にある矛盾はここで名前付きのエラーとして表面化する。 読み込み時は不正な組み合わせを Err にし、DB 側にも CHECK 制約を置いて新しい矛盾を拒みます。既存DB の形をそ のままドメイン型へ持ち込まず、境界で相互変換するのがポイントです。 61 -
最初に選ぶのは「型」ではなく「不変条件」 u64 をすべて newtype にする、と決めると変更範囲が先に膨らみます。まず、何を二度と壊したくないかを1 つ選びま す。 最初の対象に向く 間違えたときの障害や手戻りが大きい 同じ検証や取り違え対策が繰り返されている
HTTP やDB など、入口と出口を特定できる 後回しにする 仕様が探索中で、正しい状態がまだ定まらない 間違えても局所的で、修正コストが小さい 型の読み方をチームで共有できていない 型を選ぶ前に、守る不変条件を1 つ選ぶ 63 -
値の旅を、入口から保存まで1 本だけ描く 対象を CustomerId と決めたら、リポジトリ全体を直す前に、1 つのユースケースで値が通る場所を並べます。 HTTP の u64 →
Handler → UseCase → Repository → DB の BIGINT 探す場所 生の値を受け取る入口 検証や変換をしている場所 同じ型の値を渡す関数 残す成果物 最初に移行するユースケース 型へ変換する境界 生の値へ戻す出口 全呼び出しグラフではなく、変更を完結できる最短の縦1 本だけを移行単位にします。 64 -
境界に型を置き、内側のAPI を先に作る pub struct CustomerId(u64); impl TryFrom<u64> for CustomerId {
type Error = CustomerIdError; fn try_from(raw: u64) -> Result<Self, Self::Error> { if raw == 0 { return Err(CustomerIdError::Zero); } Ok(Self(raw)) } } fn find_customer(id: CustomerId) -> Result<Customer, FindError>; HTTP やDB の表現はすぐには変えません。入口で u64 を CustomerId へ変換し、新しく作る内側のAPI だけを型付き にします。 外側の互換性を保ち、内側から正しい形を作る 65 -
互換アダプタは、移行中だけ残す 既存の呼び出しを一度に直せないなら、古いシグネチャを型付きAPI への薄いアダプタにします。 fn find_customer(id: CustomerId) -> Result<Customer, FindError> {
repository::find(id) } #[deprecated(note = "CustomerId を受け取る find_customer を使う")] fn find_customer_raw(raw: u64) -> Result<Customer, FindError> { find_customer(CustomerId::try_from(raw)?) } 検証ロジックは新API へ1 か所に寄せ、旧API は変換して委譲するだけにします。旧API 側へ機能追加すると、移行経路が恒 久的な抜け道になります。 66 -
呼び出し経路を、縦に1 本ずつ移す レイヤーを横断して全部のHandler を直すのではなく、1 つのユースケースを入口からDB まで型付きにします。 1. PR 1 :
CustomerId 、生成関数、境界のテストを追加 2. PR 2 :顧客参照のHandler → UseCase → Repository を移行 3. PR 3 :次のユースケースを移行し、旧API 利用を減らす 4. PR 4 :残存利用がなくなったら互換アダプタを削除 各PR でビルド可能な状態を保ちます。コンパイルエラーは、その縦1 本の中でまだ型が届いていない場所を示します。 横に全置換せず、動く縦1 本を積み重ねる 67 -
生の値を作れる経路は、最後に閉じる 呼び出し側が移行できたら、型の壁を迂回できる生成経路を閉じます。 pub struct CustomerId(u64); // フィールドは非公開 impl CustomerId {
pub fn get(&self) -> u64 { self.0 } // DB等の出口だけで使う } #[derive(Deserialize)] struct CustomerRequest { customer_id: u64 } // 境界型は分離 直接構築できる公開フィールドをなくす deserialize 対象とドメイン型を分ける From<u64> ではなく、失敗を表す TryFrom<u64> を使う 最初から閉じると巨大な変更になります。移行経路を作ってから、抜け道を閉じるのが順序です。 68 -
旧API を消せたら、1 つの導入が完了する 「型を追加した」だけでは完了ではありません。次の状態まで到達して、初めて変更コストが下がります。 内側のAPI は u64 ではなく CustomerId を受け取る
生成と検証は境界の1 か所に集まっている 旧API と直接構築の利用箇所がなく、互換アダプタを削除できる 不正値を入れた境界テストと、代表ユースケースのテストが残る 次の不変条件へ進むのは、この縦1 本を閉じてからです。途中の移行を増やしすぎると、アダプタが新しい複雑性になり ます。 導入の単位は「型1 個」ではなく「閉じた変更経路1 本」 69 -
型エラーは、書いた瞬間に見つける 所有権、境界、DB 、既存コードには、それぞれ導入の摩擦がありました。続けやすくするには、型エラーへすぐ気 づけることが重要です。 不正な状態を型で表現できなくしても、エラーに気づくのが遅ければ使いづらくなります。rust-analyzer はコンパイ ラの診断をエディタへ届け、編集中に赤線を表示します。 rust-analyzer が壁に効く理由 編集中に型エラーを表示
newtype / enum の match 漏れも診断として表示 IDE の上で「書けないコード」が分かる 型の壁は、CI で初めて効くより、エディタで書いた瞬間に効くほうが強い。フィードバックが早いほど、壁は設計の 一部になります。 70 -
型で防げること、防げないこと ここまでの話を踏まえて、型の守備範囲を整理します。 型で防げること 「検証前の値」を「検証済みの値」として扱うこと 同じ表現の別の概念の取り違え ありえない組み合わせの同時存在 コメントだけに頼った不変条件の流出 型で防げないこと ビジネスロジックの間違い(計算式の誤り等) 外部システムの値の変化
パフォーマンスや可用性の問題 仕様の理解違い 型が防ぐのは「不正な状態が作れてしまうこと」であり、 「何が正解か」を決めるのは人間です。この区別を忘れる と、型原理主義に陥って苦しくなります。 71 -
通っても、正しいとは限らない AI に「定員に空きがあれば参加登録する」と頼むと、こんな素直なコードが出ます。型も単体テストも通ります。 let count = participant_count(event_id)?; // 取得 if
count < capacity { // 確認 insert_participant(event_id, user_id)?; // 登録 } でも残席1 のとき二人がほぼ同時に申し込むと、どちらも確認を通って2 件とも登録されます。確認と登録の間に割り込め るからです。並行性やトランザクション整合性はこの型だけでは表現できない、システム全体の制約です。 文脈を名前や型に込めれば、AI もそこから推論できるようになります。それでも、何を正しい振る舞いとするかを規定 し、最後に検証する責任は、外側を知る人間に残ります。 型は形を守る。正しさは、文脈に宿る 72 -
この考え方は、Rust に限らない ここまで、型が守れる範囲と限界まで見てきました。最後に、この設計をRust の外へ広げます。 「不正な値を型で表 現できなくする」という発想は、多くの言語で使えます。 関数型言語 F# / OCaml:
Discriminated Union Scala 3: enum (ADT 構文) Elm: Custom Types ReScript: Variants マルチパラダイム言語 TypeScript: discriminated union + literal types Kotlin: sealed class / sealed interface Swift: enum with associated values Java: sealed interface (Java 17+ ) 持ち帰っていただきたいのはRust の構文ではなく、正しい値だけを型で表す設計です。 73 -
今日の要点 設計思想 型は不正な状態を物理的に存在させない壁 バグを見つけて直すだけでなく、書ける状態の集合か ら取り除く 変更容易性と結合は、壁をどこへ建てるかを決める補 助線 読解・導入コストが便益を上回るなら、その壁は作ら ない Rust
で壁を築く道具立て 基本4 パターン:状態を分ける、意味を分ける、作り 方を絞る、正しい組み合わせだけ残す Rust 固有:型状態 / PhantomData / 既製の制約型 進化のとき:フィールドを足さず、新しい型を作る 導入:不変条件を1 つ選び、縦1 本を移して旧API を閉 じる バグを直すな。表現できなくせよ。 75 -
入力と出力では、壁の向きが違う 入力型 狭くすると、不正な値を入口で拒否できる 関数の内側は再検証せずに済む 呼び出し側は型を作る責任と変換コストを負う 呼び出し側の自由を、受け取る側の保証へ交換する。 出力型 豊かにすると、下流は型を使って判断できる エラーや状態を網羅的に処理できる 公開した知識が、提供側の将来変更を拘束する
提供側の自由を、利用側の判断力へ交換する。 近くで共に進化するAPI なら豊かな型を共有し、遠くで独立して進化するAPI なら用途別の小さな出力へ絞ります。 型の強さではなく、変更する側と保証を受ける側を選ぶ 76 -
参考資料① 書籍と記事 Programming Rust, 3rd Edition (Early Release ) (Jim
Blandy, Jason Orendorff, Leonora F. S. Tindall 著, O'Reilly, Rust 2024 Edition 対応, 2026 年7 月参照) 第9 章 構造体(タプル構造体とニュータイプ) 第10 章 列挙型とパターン(代数的データ型と match ) Domain Modeling Made Functional (Scott Wlaschin 著, Pragmatic Bookshelf, 2018 ) 第4 章 Understanding Types (choice types / discriminated unions ) 第5 章 Domain Modeling with Types (Constrained Values, Modeling with Choice Types ) 第6 章 Integrity and Consistency in the Domain (smart constructors / 不正な状態を表現不可能にする / "compile-time unit tests" ) "Designing with Types" シリーズ(Scott Wlaschin, F# for Fun and Profit, 全13 回) 代表記事: 「不正な状態を表現不可能にする」 シリーズ目次: fsharpforfunandprofit.com/series/designing-with-types/ "Parse, don't validate" (Alexis King, 2019 ) lexi-lambda.github.io/blog/2019/11/05/parse-don-t-validate/ "Rust でも学べる関数型ドメイン駆動設計" (nwiizo, 2026 ) syu-m-5151.hatenablog.com 77 -
参考資料② 結合と変更容易性 Balancing Coupling in Software Design (Vlad Khononov )
第1 章:結合は接続であり、システムに不可欠 第8 章:距離が大きいほど、連鎖的変更の調整コストが増える 第10 章:統合強度・距離・変動性による結合のバランス 第11 章:戦略・組織・環境の変化に応じたリバランス 第14 章:共に変わるものを近く、独立して変わるものを遠くへ置く 日本語翻訳プロジェクト github.com/nwiizo/balancing-coupling-in 本資料ではFigure 8.2 、Figure 10.1 、Figure 14.1 を引用 本資料での接続:型は結合を消す道具ではなく、一緒に変わる知識を近くへ集め、境界を越える知識を制御する道具と して扱う。 78