Upgrade to Pro — share decks privately, control downloads, hide ads and more …

型は壁、Rustでもバグを直すな、表現できなくせよ

Avatar for nwiizo nwiizo
July 11, 2026

 型は壁、Rustでもバグを直すな、表現できなくせよ

関数型まつりで登壇したりします。
https://2026.fp-matsuri.org

Avatar for nwiizo

nwiizo

July 11, 2026

More Decks by nwiizo

Other Decks in Technology

Transcript

  1. 型は壁、Rust でもバグを 型は壁、Rust でもバグを 直すな、表現できなくせよ 直すな、表現できなくせよ is_paid = true is_paid

    = true なのに なのに payment_id payment_id が null 、という話 が null 、という話 関数型まつり2026 公募セッション @nwiizo 50min
  2. 今日お話しすること 1. なぜ型を「壁」にするのか 2. 関数型の考えをRust で使う 3. 不正な値を作らせない4 つのパターン 4.

    Rust の所有権と型状態を使う 5. DB や既存コードとの境界を設計する 6. まとめ 関数型まつりの参加者には当たり前の内容も含まれます。直和型や Option / Result は短く確認し、Rust で使うと何が 起きるかに時間を使います。 Rust が初めての方へ:記号は登場時に説明します。コードは、細部より「何を受け取り、何を返すか」を追ってください。 4
  3. この発表で解決できること こんな状態に身に覚えはありませんか? is_paid = true なのに payment_id が null status

    = "verified" なのに verified_at が欠 落 CustomerId と OrderId が同じ u64 で取り違え この発表で持ち帰れるもの 型を「壁」として使う設計思想 関数型の道具をRust に持ち込む具体パターン 所有権・型状態・ PhantomData でさらに強化する方 法 AI 時代でも機械的に検査できる制約の作り方 バグは、対処するものから、書けないものへ 5
  4. 矛盾はあとから効いてくる 問題は、値を組み合わせた瞬間に起きます。 is_paid = true なのに payment_id が null どの決済で完了したかの記録がなく、返金も、会計との

    突き合わせもできない。 status = "verified" なのに verified_at が null 再認証ポリシーは比較の基準日を持てず、落ちるか素通 りするかの二択になる。 こうしたレコードを作った瞬間にアラートが鳴ったりはしません。静かにDB に残り、数ヶ月後に返金業務や再認証処理 で突然例外を投げる。 単独では正しい値が、組み合わせると矛盾する 8
  5. なぜ、型はこれを止められなかったのか 先ほどのレコードを受け取る型は、もしかしたらこう書かれていたかもしれません。 pub struct Order { pub is_paid: bool, pub

    payment_id: Option<PaymentId>, } この型は、 bool と Option<PaymentId> の任意の組み合わせを許します。 「正しい組み合わせ」と「あり得ない 組み合わせ」を区別する情報は、型のどこにも書かれていません。 つまり、型が「正しい形」を規定していない。正しさはコメントやバリデーション関数の中に散らばり、どこかで抜 け落ちる。抜け落ちた瞬間、矛盾レコードが静かに誕生します。 9
  6. AI Slop 問題は、コードにもやってくる AI Slop は、AI によって作られる低品質なデジタルコンテンツを指す言葉です。ソフトウェア開発でも、生成コードだけ でなく、PR ・文書・バグ報告にまで及ぶと報告されています。 生成する側

    短時間で、もっともらしいコードを大量に作れる。 レビューする側 ドメインの制約や文脈を、1 件ずつ検証する必要があ る。 生成量が増えるほど、コメントに書いた制約は有限のレビュー予算を奪い合います。生成速度にレビューが追いつかなけ れば、人間は疲弊し、問題が見落とされたまま低品質なソフトウェアがリリースされます。 生成コストは下がる。検証コストは消えない 出典: Baltes, Cheong, Treude (2026) “An Endless Stream of AI Slop” 11
  7. 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 時代こそ、自然言語の「お 願い」を、コンパイラが検査できる「壁」へ移します。 型は、お願いをビルドエラーに変える 12
  8. 壁は、設計図ごと壊せる 型は強い壁ですが、無敵ではありません。壁を迂回する経路は3 つあります。 unsafe / unchecked API :コンパイラが証明できない安全条件を人間が引き受ける 公開フィールドや未検証の deserialize

    :検証付き生成関数を通らない生成経路を残す 型定義そのものを緩めるPR : NonZero を u32 に戻す、enum に Option を足す コメントは黙って破れますが、型の壁を迂回すると痕跡が残ります。 unsafe や unchecked API は呼び出しとし て、型定義の変更は diff として可視化される。 型は壁ですが、壁の設計図を守るのは人間のレビューです。AI が型定義を緩める PR を出したとき、気づけるのは人 間だけ。型はコードレビューを不要にはしません。 型は壁。でも、壁の設計図を守るのは人間 13
  9. 型は、値の集まりに付ける名前 関数型まつりの皆さんにはおなじみですが、ここでは Rust の例で確認します。 型 = 値の集合に付けた名前( bool = {true,

    false} 、 u64 = 0 〜約1800 京) 型を「値の集まり」と考えると、設計の狙いが単純になります。正しい値だけが入るように、集合を小さくするのです。 たとえば「0 ではない整数」という型を作れば、0 はその集合に入りません。0 を見つけて弾くのではなく、最初から表現 できなくします。 型を狭くすると、バグの置き場所も狭くなる 15
  10. 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 型は候補を並べるので足し算になります。 掛け算で増えた組み合わせに、バグが潜む 16
  11. 関数型の道具は、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 では、そこへ参照・可変性・メモリ安全性を検査する借用チェッカ ーも組み合わさります。 17
  12. Rust の記号は、ここだけ読めればいい 本編で頻繁に登場する記号だけ先に整理します。これでコード例は雰囲気で読めます。 参照と所有権 &T :読み取り専用で借りる &mut T :書き換え可能な状態で借りる 参照なし(

    T ) :所有権ごと渡す。呼び出し元では使 えなくなる エラー処理 Result<T, E> :成功 Ok(T) か失敗 Err(E) ? 演算子:失敗ならその場で返し、成功なら中身を 取り出す Option<T> :値がある Some か、ない None 細かい記号は登場時に補足します。構文に悩んだら「型が何を受けて何を返すか」だけ見てください。 18
  13. ? は、失敗した時点で処理を返す ? は、処理に失敗したらその場で呼び出し元へ返し、成功したら中身を取り出します。 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 を書く必要はありません。 19
  14. 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 ではないことが 関数の型に現れます。 21
  15. 線形型とアフィン型は、使える回数が違う 値を何回使えるかに注目すると、Rust の所有権を整理できます。 線形型 (Linear types) 値はちょうど1 回使います。捨てることも複製すること もできません。 代表例:

    Clean の uniqueness types 、Idris 2 の linear types アフィン型 (Affine types) 値は1 回以下使います。複製はできませんが、捨てるこ とはできます。 代表例: Rust の所有権、ATS 、Vale Rust の通常の所有値は、捨ててもよいアフィン型の側です。 Girard (1987) "Linear Logic" / Bernardy, Boespflug, Newton, Peyton Jones, Spiwack (2018) "Retrofitting Linear Types" (POPL'18) 22
  16. Rust の所有値は、高々1 回だけ使える Copy ではない値を関数へ渡すと、所有権が移ります。同じ値をもう一度使おうとすると、コンパイルエラーになり ます。 let order = UnvalidatedOrder::new();

    validate(order)?; // ここで所有権が移る validate(order)?; // エラー。order はもう使えない 一方、使わずに捨てることはできます。 Copy 型は複製できる例外で、 #[must_use] は捨てた値を警告する仕組み です。 この「使い回せない」という制約が、古い状態を残さない壁になります。 23
  17. パターン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 フラグは実行時の情報であり、呼び出し側が確認を忘れてもコンパイラは何も言いません。 25
  18. 別の型にすれば、順序違反は書けない 検証前と検証後を別の型にします。 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) はコンパイルエラーになります。 「検証前の注文を価格計算に渡す」バグは、そも そも書けません。 26
  19. ワークフロー全体を型で貫く 状態ごとに型を分ける発想を注文処理の一連の流れに適用すると、こうなります。 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>; 各ステップの入出力が型で固定されているので、順序を間違えるコードは書けません。検証前の注文に価格をつけるコー ドも、支払い前の注文を確定するコードも、コンパイルが通りません。 関数の入出力の型が、ワークフローの仕様書になる 27
  20. ワークフローは左から右へ流せる 状態型で固めた各ステップは、関数合成でそのまま繋がります。 tap クレートの .pipe() を挟むと、ネストせず左か ら右に読める形になります。 let paid =

    raw .pipe(validate) // Unvalidated → Result<Validated> .map(price) // Validated → Priced .map_err(WorkflowError::from) .and_then(|o| charge(o, &card).map_err(WorkflowError::from))?; パイプの途中で型が合わなければ、その行が赤くなります。合成できる順序はコンパイラが保証する。関数型の「データ を変換で流す」発想が、状態遷移にそのまま乗ります。 28
  21. パターン2 同じ数字でも、意味ごとに型を分ける 状態型(パターン1 )とその応用はここまで。残り3 つのパターンに進みます。まずは取り違えの防止。 顧客ID と注文ID 、どちらも u64 で扱うと、引数の順序ミスがすり抜けます。

    fn charge(customer_id: u64, order_id: u64) { /* ... */ } let customer = 1001u64; let order = 5678u64; charge(order, customer); // ← 引数逆でもコンパイルが通る これは実行時に初めて気づくバグです。ユニットテストを全網羅しない限り、本番で発覚します。 29
  22. 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 に別の意味を持たせると、必ずどこかで混ざる 30
  23. パターン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 は以降のコードで必ず @ を含みます。 31
  24. 複数の制約を組み合わせる 実務では複数の制約を束ねることがよくあります。 pub struct CustomerName { first: String, last: String

    } impl CustomerName { pub fn new(first: &str, last: &str) -> Result<Self, NameError> { let first = first.trim(); let last = last.trim(); if first.is_empty() || last.is_empty() { return Err(NameError::Empty); } if first.chars().count() > 50 || last.chars().count() > 50 { return Err(NameError::TooLong); } Ok(CustomerName { first: first.into(), last: last.into() }) } } このコンストラクタを通った CustomerName は、空でも長すぎもしないことが型で保証されます。 32
  25. 検証した印を、型に残す 検証付き生成関数の考え方を、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/ ) 33
  26. この型、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) 不正:未認証なのに日時がある 34
  27. 型の半分が、バグの置き場所になる 4 通りのうち、業務上正しいのは2 通りだけです。 型が許す状態 bool 2 通り × Option

    2 通り = 4 通り 業務で正しい状態 未認証 + 認証済み = 2 通り AND 型は、フィールドの選択肢を掛け算します。フラグを足すたびに状態は増えますが、増えた状態が正しいと は限りません。 正しい状態が2 つなら、型にも2 つだけ書く 35
  28. 正しい状態だけをenum に書く フラグと日時を別々に持たず、 「未認証」か「認証済み」のどちらか一方を enum で表現します。 enum User { Unverified

    { email: String }, Verified { email: String, verified_at: DateTime<Utc> }, } Verified なら verified_at は必ず存在します。 Unverified には、そのフィールド自体がありません。 4 通りを許してから2 通りを弾くのではなく、正しい2 通りだけを最初から型に書きます。 36
  29. match は、状態の書き忘れも防ぐ enum を使うコードは、 match で状態ごとの処理を書きます。 fn send_receipt(user: &User) {

    match user { User::Verified { email, verified_at } => { /* 送る */ } User::Unverified { .. } => { /* 送らない */ } } } どちらかの状態を書き忘れると、コンパイラがエラーにします。新しい状態を追加したときも、修正が必要な match がすべて分かります。 不正な状態を表現不可能にする 37
  30. 型は、コンパイル時のユニットテストになる enum で「ありえない状態」を書けなくすると、もう1 つ利点があります。 「認証済みなのに verified_at が null のケース」のテストは、書く必要がなくなります。 なぜなら、そのケースはコンパイラが通さないからです。

    この仕組みを、Scott Wlaschin は「コンパイル時のユニットテスト」と呼びます。型が、実行前にテストの役目を果 たしてくれるのです。テストコードが不要になるのではなく、型そのものがテストになります。 38
  31. 4 つの基本パターン、一覧で確認 ここまで駆け足で見てきた4 パターンを、一枚にまとめます。 1. 状態ごとに型を分ける UnvalidatedOrder と ValidatedOrder は別の型。検証

    前の注文を価格計算に渡すコードは、書けない。 2. 意味ごとにnewtype を作る CustomerId と OrderId を別の型に。引数の順序を間違 えればコンパイルが通らない。 3. 検証付き生成関数を通す 作り方を限定すれば、不正な Email は存在できない。検証 済みという情報が型に残る。 4. 正しい組み合わせだけをenum にする フラグと Option を別々に持たず、正しい状態だけを候補と して並べる。 この4 つだけでも、 『is_paid と payment_id が矛盾する』系のバグは書きようがなくなります。 39
  32. 状態を型パラメータに乗せる 先ほどは UnvalidatedOrder と ValidatedOrder を別々の構造体にしました。中身がほぼ同じなら、注文は1 つ のまま、状態だけを型で切り替えられます。 型状態(Type State

    )パターン Order<Unvalidated> と Order<Validated> は、中身が同じでも別の型です。検証が終わるまで、検証済み の注文だけに許した操作は呼べません。 別々の構造体を何度も書かずに、状態遷移を型で表せるのが利点です。 41
  33. PhantomData は、実体を増やさず状態を載せる Order<State> と書いても、注文のフィールドには State の値がありません。そこで PhantomData を使い、こ の型はState に依存するとコンパイラへ伝えます。

    use std::marker::PhantomData; struct Order<State> { items: Vec<Item>, _state: PhantomData<State>, } PhantomData<State> は実行時には0 バイトです。データを増やさず、型の上だけに状態を追加します。 42
  34. 型状態パターンのコード例 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 の注文は検証後には残りません。 43
  35. 自作する前に、既製の制約型を探す ここまでの壁は全部自作でした。でも、自作する前にすでに用意されている制約型で済まないかを確認するのが先です。 標準ライブラリとエコシステムに、制約が型に埋め込まれた型はいくつも揃っています。 標準ライブラリ 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 では再確認が不要になります。 44
  36. 公開API の拡張点を、型で閉じる 型は壁ですが、公開API の拡張点には穴が残ります。 enum はもともと閉じていますが、 trait は放っておくと外部の コードから実装を増やせます。 そこで

    trait を非公開の supertrait で封じます(sealed trait ) 。 mod sealed { pub trait Sealed {} } pub trait PaymentState: sealed::Sealed { /* ... */ } pub struct Authorized; impl sealed::Sealed for Authorized {} // 実装できるのは自分のクレートだけ impl PaymentState for Authorized { /* ... */ } sealed::Sealed が非公開なので、外部クレートは PaymentState を実装できません。 「どこまで拡張を許すか」を、 お願いではなく型で決められます。 45
  37. フィールドを足すとOption の海に戻る 要件が増えたとき、つい既存の型にフィールドを足したくなります。でもそれは、組み合わせ爆発とOption の海に戻る道 です。 // Before: ValidatedOrder に配送料の情報を足したい struct

    ValidatedOrder { items: Vec<Item>, shipping_cost: Option<Money>, // ← 計算前は None shipping_address: Option<Address>, // ← 計算前は None } 配送料が計算済みかどうか、配送先が確定しているかどうかが、また Option の組み合わせに散らばります。 46
  38. 設計を進化させるときも、型を作る 新しい状態は、新しい型にする。配送情報が必要になったら、それを必須フィールドに持つ型を作ります。 struct PricedOrder { items: Vec<Item>, subtotal: Money }

    struct PricedOrderWithShipping { // 配送情報は必須・Optionにしない items: Vec<Item>, subtotal: Money, shipping: ShippingInfo, } 型が増えると、コンパイラが依存箇所を全部追跡します。 「直し忘れ」はビルドエラーになる。 Option で様子を見るよ り安全です。 ここまでは型で何でも解決できそうに見えます。でも、Rust 固有の摩擦もあるのが現実です。 47
  39. 摩擦1 古い状態を借りたまま、次へ進めない ここまで型で何でも守れるかのように話してきました。でも、実際にコードを書くと、最初につまずくのがここで す。 状態遷移を fn validate(o: UnvalidatedOrder) -> Result<ValidatedOrder,

    _> の形で書くと、入力の注文 は所有権ごと消費されます。 これが摩擦になるのは、古い注文への参照を残したまま、次の状態へ進めたい場面です。 let order: UnvalidatedOrder = receive(); let audit_view = &order; // 古い状態を借りる let valid = validate(order)?; // ← 借用中なので move できない log_for_audit(audit_view); // 借用がここまで生きている 監査ログやメトリクスへ古い値を渡したいなら、必要な情報だけコピーするか、共有したい部分を Arc に分けま す。古い状態を誰がいつまで見るのかを明示的に決める必要があります。 49
  40. 共有したい中身だけ、構造共有する 注文全体ではなく、大きなコレクションだけを新旧の状態で共有したいなら、永続データ構造が使えます。 im-rc の Vector / HashMap は、更新しても古い値を残し、新しい値を返します。 use im_rc::Vector;

    fn add_item(items: &Vector<Item>, new: Item) -> Vector<Item> { let mut next = items.clone(); // clone は構造共有なので安価 next.push_back(new); // next だけが伸びる next // 元の items は不変のまま残る } この clone() は全要素を複製せず、変更していない部分を新旧の値で共有します。これを構造共有と呼びます。型状態 の保証を保ったまま、複製コストを抑えられます。 50
  41. 摩擦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 します。トレードオフは保証の強さではなく、重複 と操作の近さです。 51
  42. 摩擦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 は何か」 「どの状態へ進むのか」を追う負荷が増えます。 52
  43. 外から来る値には、まだドメイン型がない 型で守れるのは、業務ルールを扱うドメインの内側です。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 へ変換します。 54
  44. 境界で型に変換し、内側へ渡す 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 か所で検証して型に変換します。以降は、型付きの値だけが内側を流れます。 境界で型を貼り、内側は型で守る 55
  45. 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()), }, } } 56
  46. 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 の形をそ のままドメイン型へ持ち込まず、境界で相互変換するのがポイントです。 57
  47. 既存コードへの段階導入 u64 が配り回っているコードベースに newtype を一気に入れるのは大工事です。影響範囲が全ファイルに及ぶこと もあります。 現実解は、境界から小さく導入することです。 1. API 入口:HTTP

    ハンドラや deserialize 直後で newtype に変換 2. リポジトリ層:DB から取り出した直後、返す型を newtype に 3. 内側に染み込ませる:呼び出されている関数の引数・戻り値の型を順次 newtype に置き換え すべてを一度に直す必要はありません。境界に置いた newtype が、内側の型付けを徐々に強制していくのが実務的 なパターンです。 58
  48. 型で書けない規約は、自動検査する rowan は、ソースコードをコメントや空白も含めて木構造で扱うライブラリです。コードの形を調べられるので、チ ーム固有の規約を検査するlint の土台にできます。 rowan で検査できる例 pub …_id: String

    → newtype に矯正 is_paid: bool + payment_id: Option → enum 化を促す pub enum …Error に #[non_exhaustive] 必須 cargo check が知らないチーム固有の規約も、lint なら自動で検査できます。機械に任せる範囲を増やすほど、人間 は仕様そのものが正しいかの確認に集中できます。 CI だけで弾くな。書いた瞬間にも弾け 60
  49. 通っても、正しいとは限らない AI に「定員に空きがあれば参加登録する」と頼むと、こんな素直なコードが出ます。型も単体テストも通ります。 let count = participant_count(event_id)?; // 取得 if

    count < capacity { // 確認 insert_participant(event_id, user_id)?; // 登録 } でも残席1 のとき二人がほぼ同時に申し込むと、どちらも確認を通って2 件とも登録されます。確認と登録の間に割り込め るからです。並行性やトランザクション整合性はこの型だけでは表現できない、システム全体の制約です。 文脈を名前や型に込めれば、AI もそこから推論できるようになります。それでも、何を正しい振る舞いとするかを規定 し、最後に検証する責任は、外側を知る人間に残ります。 型は形を守る。正しさは、文脈に宿る 62
  50. この考え方は、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 の構文ではなく、正しい値だけを型で表す設計です。 63
  51. 今日の要点 設計思想 型はコンパイラのための注釈ではなく、不正な状態を 物理的に禁止する壁 バグは直すのではなく存在させない AI が書く時代も破れない壁は型。正しさが宿る文脈の 検証は人間に残る Rust で壁を築く道具立て

    基本4 パターン:状態を分ける、意味を分ける、作り 方を絞る、正しい組み合わせだけ残す Rust 固有:型状態 / PhantomData / 既製の制約型 進化のとき:フィールドを足さず、新しい型を作る 境界:外から来た値を型に変換し、内側は型で守る バグを直すな。表現できなくせよ。 65
  52. 参考資料① 書籍と記事 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 66
  53. 参考資料② 理論的背景とツール 線形型・アフィン型の理論的背景 Girard (1987) "Linear Logic" 。線形論理の原典(線形型の理論的源流) Bernardy, Boespflug,

    Newton, Peyton Jones, Spiwack (2018) "Retrofitting Linear Types" (POPL'18 ) 。既存の言語に線形型を後 付けする設計論文 Walker (2005) "Substructural Type Systems" (部分構造型システム概観) 型を IDE と Linter で常時強制する基盤 rust-analyzer: github.com/rust-lang/rust-analyzer 。IDE 向けRust 解析サーバ。型エラーや match 漏れをリアルタイムで弾く rowan: github.com/rust-analyzer/rowan 。rust-analyzer でも使われるロスレス構文木ライブラリ。構文解析層と組み合わせて自 家製lint の土台にできる 補足: 「不正な状態を表現不可能にする」という表現は、Yaron Minsky の "Effective ML" で提示され、Scott Wlaschin が F# とドメインモデリングの文脈で 広く紹介しています。 67