はじめに
長年、動いた DB を覗くと、たいていこういうレコードが眠っています。
{ "order_id": 12345, "is_paid": true, "payment_id": null }
is_paid = true なのに payment_id は null。 ドメイン上、 同時に成立してはいけない組み合わせ が静かに残り、数ヶ月後の返金処理で例外を出します。
今後 「型で illegal state を表現不能にする」みたいな発表をします 。 そしてその前の記事で書いた 「rowan で自家製 lint を書く」 は、 cargo check に乗らない規約をコードで強制する話でした。 この 2 つを 組み合わせて実プロジェクトに入れるとどうなるか が今回のテーマです。

型は壁、Rustでもバグを直すな、表現できなくせよ by nwiizo | トーク | 関数型まつり2026 #fp_matsuri - fortee.jp
順番:
- ドメインを enum で型の壁にする (= 新規コードへのガード)
- 既存コードベースに残る古いパターンを rowan の lint で網を張る (= 後戻りのガード)
- lint を
build.rs/xtaskで常時走らせて CI で機械化する - 古いコードを少しずつ enum 化しながら lint を緩めていく
題材は Order ドメイン。 「未払い / 支払い済み」 を is_paid: bool + payment_id: Option<_> で表現していたコードベースに、型と lint の 2 段の壁を入れていきます。
第一の壁 — 型で illegal state を消す
問題のある struct はこうでした。
pub struct Order { pub id: u64, pub is_paid: bool, pub payment_id: Option<u64>, }
このとき作れる組み合わせは 4 通り。 「未払い・決済 ID なし」 と 「支払い済み・決済 ID あり」 の正常 2 通りに加えて、 「未払いだが決済 ID がある」 「支払い済みだが決済 ID がない」 の 不正 2 通りも書けてしまう 。 4 通り全部にコメント書いてレビューで潰す、では運用できません。
正常な状態だけを enum で並べ直します。
#[derive(Debug)] pub struct OrderId(u64); #[derive(Debug)] pub struct PaymentId(u64); #[derive(Debug)] #[non_exhaustive] pub enum PaymentStatus { Unpaid, Paid { payment_id: PaymentId, paid_at: chrono::DateTime<chrono::Utc>, }, } pub struct Order { pub id: OrderId, pub status: PaymentStatus, }
これで 「未払いだが決済 ID あり」 は 書こうとしても書けません 。 Unpaid には payment_id フィールドがそもそも存在しない。 match で書けば、 将来 Refunded を追加した瞬間に 未対応の関数が全部コンパイルエラーになり、 修正漏れが起こせなくなります。
ここまでが前の記事 「illegal state を表現不可能にする」 の振り返り。 ここから先が今回の本題です。
なぜ型だけでは止まらないのか
問題は 既存コードベース です。 5 年動いてきたサービスのコードは、 こうなっています。
- ハンドラは
Order { id, is_paid, payment_id }を受け取ってserde::Deserializeでビルドする - repository 層は DB の
is_paid BOOLEAN, payment_id BIGINT NULLをそのままOptionにする - 業務コードのあちこちで
if order.is_paid && order.payment_id.is_some() { ... }のガードが書かれている
ここに 「Order を enum にしましょう」 を一気に投げると、 ハンドラ・リポジトリ・ガード・テストが 全ファイル巻き込まれて 数日仕事になります。 そして、 その間も誰かが古い形で pub struct Order { is_paid: bool, payment_id: Option<u64> } を別ドメインに書き足す可能性が消えません。
新規の侵入を コンパイラで止める のが lint の役目です。 cargo check は 「型として正しいか」 しか見てくれません。 「同じ struct に is_paid: bool と payment_id: Option を共存させたら警告」 のような規約は、 自分で書く必要があります。
第二の壁 — rowan で bool-option-pair lint を書く
rowan は rust-analyzer 内部の構文木ライブラリで、 ra_ap_syntax を経由すれば Rust grammar をそのまま借りられます。 構文木を歩いて、規約違反を見つけたら診断を吐くだけ。
最小実装はこうなります。
use ra_ap_syntax::ast::{self, AstNode, HasName, HasVisibility}; pub struct BoolOptionPair; #[derive(Debug)] pub struct Diagnostic { pub rule: &'static str, pub message: String, pub line: u32, } impl BoolOptionPair { pub fn check(&self, file: &ast::SourceFile, source: &str) -> Vec<Diagnostic> { let mut diags = Vec::new(); for node in file.syntax().descendants() { let Some(strukt) = ast::Struct::cast(node) else { continue }; let Some(ast::FieldList::RecordFieldList(fields)) = strukt.field_list() else { continue; }; // public な bool フィールドと Option フィールドを集める let mut bool_flags: Vec<(String, ra_ap_syntax::TextRange)> = Vec::new(); let mut options: Vec<String> = Vec::new(); for field in fields.fields() { if field.visibility().is_none() { continue } let Some(name) = field.name() else { continue }; let Some(ty) = field.ty() else { continue }; let nm = name.text().to_string(); let tt = ty.syntax().text().to_string(); if tt == "bool" { bool_flags.push((nm, field.syntax().text_range())); } else if tt.starts_with("Option<") { options.push(nm); } } // ドメイン規約: `is_*` / `has_*` / `was_*` のフラグと // `_id` / `_at` で終わる Option が同じ struct にいるとき警告 let suspicious_option = options.iter().any(|n| { n.ends_with("_id") || n.ends_with("_at") || n.ends_with("_by") }); for (name, range) in &bool_flags { let prefixed = name.starts_with("is_") || name.starts_with("has_") || name.starts_with("was_"); if prefixed && suspicious_option { let line = line_of(source, range.start()); diags.push(Diagnostic { rule: "bool-option-pair", message: format!( "`{name}: bool` と隣接する `Option<_>` フィールドは相関状態を許す。\ enum で variant に揃える" ), line, }); } } } diags } } fn line_of(source: &str, offset: ra_ap_syntax::TextSize) -> u32 { let target: usize = offset.into(); source[..target.min(source.len())].matches('\n').count() as u32 + 1 }
100 行弱。 ヒューリスティックは荒いですが、 これでさっきの Order struct はちゃんと引っかかります。
let src = r#" pub struct Order { pub id: u64, pub is_paid: bool, pub payment_id: Option<u64>, } "#; let parse = ra_ap_syntax::SourceFile::parse(src, ra_ap_syntax::Edition::Edition2024); for d in BoolOptionPair.check(&parse.tree(), src) { println!("L{}: [{}] {}", d.line, d.rule, d.message); } // L4: [bool-option-pair] `is_paid: bool` と隣接する `Option<_>` フィールドは相関状態を許す。enum で variant に揃える
cargo check も clippy もこの違反を見ません。 自分で書いてはじめて、 ドメイン語彙が壁になります。
ヒューリスティックを育てる
最初の lint は荒くて構いません。 偽陽性 (false positive) が出たら、 そのコードを見て妥当な反論 を書き、 ヒューリスティックを絞ります。 たとえば:
is_premium: bool+subscription_url: Option<Url>のような 完全独立な 2 値 は、 stem を要求して回避できる (is_paid↔payment_idのように 語幹が部分一致 する場合のみ警告)#[serde(default)]の DTO は外部入力を一旦受けるための型なので、 内側に拡散しない限り許可する → struct に#[derive(Deserialize)]がついていたらスキップする
「正しい lint」 は最初から書けません。 ドメインの理解が深まるごとに、 ヒューリスティックを足したり緩めたりする のが運用です。 lint も育てる対象です。
lint と test の棲み分け
「ドメイン規約を lint で書ける」 と言うと、 ほぼ必ず 「テストでよくないか?」 と問われます。 答えは どちらも要る、 ただし担当が違う です。 自分の samples/rust-types-as-walls で cargo test --tests を回すと、 9 suites で 22 件が並走します。 内訳を見ると、 道具を揃えた意図がはっきりします。
| 守りたいこと | 道具 | 具体例 |
|---|---|---|
| 型として正しいか (網羅性、所有権、取り違え) | コンパイラ | match PaymentStatus の variant 網羅、UserId と OrderId の混合禁止 |
「同じ struct に bool + Option を共存させない」 ような構造規約 |
rowan lint | bool-option-pair, raw-id-field, non-exhaustive-pub-error |
| 「このコードはコンパイルエラーであるべき」 という API 契約 | trybuild UI test | non_exhaustive_match_fail, sealed_trait_external_impl_fail (tests/fixtures/) |
| パース成功 → 値の不変条件を満たす | property test (proptest) |
Password::new が返す値が長さ・文字種を必ず満たす |
| ドメインの振る舞い | integration test | 「未払い注文を ship しようとしたら 4xx」 |
線の引き方は明快です。 構造的・静的なルールはコンパイラ + lint、 値・振る舞いのルールは property + integration test 。 is_paid: bool + payment_id: Option<_> を弾くのは前者なので lint、 「Order::pay() を 2 回呼んだら error」 は後者なのでテスト。 互いに領域が重ならず、 置換できません。
4 段で何が止まるかを揃える
CI で 4 段全部回すと、 PR が どの壁を破ろうとしているか をレビュー前に分けて見られます。
| ステップ | 通る条件 | 落ちたとき直す場所 |
|---|---|---|
cargo build |
型として正しい | プロダクトコードの型 |
cargo xtask lint |
ドメイン規約に違反しない | 構造を直すか、 lint を緩める判断 |
cargo test --tests (trybuild) |
「コンパイルエラーになるべき」 が壊れていない | API 契約のレグレッション |
cargo test --tests (proptest / integration) |
値・振る舞いが期待通り | 実装のバグ |
samples/rust-types-as-walls の test 構成は、 この 4 段の 3 段目と 4 段目 を担当しています。 tests/fixtures/non_exhaustive_match_fail/ は 「#[non_exhaustive] enum を _ => 無しで match するとコンパイルエラーになる」 を契約として固定し、 tests/password_props.rs は 「Password::new が返す全候補が不変条件を満たす」 を property で固定する。 lint (2 段目) を加えると、 「そもそも password: String のような raw 型を field に晒さない」 という 構造の話 がここに刺さります。
lint に寄せすぎてはいけないもの
逆方向の境界線も同じくらい重要です。 lint で書けてしまうが、 書くべきでない ものがあります。
- 値の中身に依存するチェック (例: 「
due_atはcreated_atより後」)。 値が走らないと判定できないので property test の領域。 lint で正規表現を書き始めたら警報。 - 複雑なドメイン式 (例: 税計算、 割引)。 ユニットテストで十分。 lint に持ち込むと、 荒い AST マッチングで偽陽性が増えます。
- 言語仕様レベルの一般則 (例: 「
Resultを返す関数で?を使うべき」)。 clippy が既に持っているなら、 自家製で書き直さない。
「コンパイラが見逃す構造的な規約」 だけが、 自家製 lint の領域です。 ここを越えるとテストや clippy と仕事が被って、 メンテナンスコストだけが増えます。
偽陽性が出たときの逃がし方
運用していると、 ドメイン的に正当な struct がたまたま lint に引っかかる事故が必ず起きます。 そのときの逃がし方を最初から決めておきます。 自家製 lint は 2 段階で逃がせるように作っておくのが、 実運用に耐えるための最小要件 です。
1. コメントで局所的に逃がす — 違反箇所だけ・直前のコメント行で抑制。
// rbp-lint-allow: bool-option-pair (reason: 旧スキーマ互換のため正しく相関しない) pub struct LegacyOrder { pub is_paid: bool, // ← この struct 内では bool-option-pair を抑制 pub payment_id: Option<u64>, } let raw = std::fs::read_to_string("/etc/passwd").unwrap(); // rbp-lint-allow: no-unwrap
行末・直前数行・ファイル先頭の // コメントを lint 側でスキャンします。 同一行末尾は その行の違反だけ、 直前の連続コメント行は 次の違反 1 件、 ファイル先頭 (//! 含む) は そのファイル全体 に効きます。
2. プロジェクト単位は .rbp-lint.toml で逃がす — ルールごとの severity 上書き、 パスごとの除外。
# .rbp-lint.toml (リポジトリルートに置く) [rules] no-unwrap = "error" # 既定どおり強制 bool-option-pair = "warning" # 段階導入の途中は warning に下げる debug-print = "off" # CLI バイナリでは println! を許す [paths] exclude = ["**/examples/**", "vendor/**", "**/*_generated.rs"]
off にすると lint そのものを実行しない。 既存違反が大量に残るプロジェクトに lint を入れるとき、 まず全部 warning で着地して、 1 つずつ消えたら error に上げる、 という運用が成り立ちます。
ここで揃えたいのが、 sample の test ファイル冒頭で見える Rust 1.81+ 流儀です。
#![allow( clippy::expect_used, clippy::panic, clippy::unwrap_used, reason = "tests keep assertions and fixture setup direct" )]
reason = "..." を必須にする慣習を、 自家製 lint の suppression にも持ち込みます。 // rbp-lint-allow: rule (reason: ...) という書式を コードレビューで強制 すれば、 「許す」 ことは許すが 理由なしの許可は許さない 状態になります。 「ここは bool-option-pair を抑制してるが、 なぜ?」 がコードに残る。 lint が厳しすぎても緩すぎても運用が壊れる中で、 この第三の道だけが続きます。
まだ実装していない逃がし方: 「baseline」 — git diff main で新規追加された違反だけ報告する機能です。 巨大コードベースに lint を一気に入れるとき、 既存違反を「いまある分は放置、 新規だけ警告」 にできれば段階導入が一気に楽になります。 --baseline=main.json でスナップショットを許可リストとして渡す設計が定石ですが、 これは別記事で。 当面は .rbp-lint.toml の warning 段階と、 違反箇所への suppression コメントで代替できます。
CI に組み込む — 違反コードを commit させない
lint を CLI で書いただけでは、 走らせ忘れた瞬間に違反が混ざります。 自家製 lint を cargo build か cargo xtask lint で常時走らせる 仕組みを必ずセットで作ります。
xtask パターン (推奨)
workspace に xtask/ を切って、 cargo xtask lint で起動します。 開発中の cargo check を遅くしないので、 普段使いに向いています。
# .cargo/config.toml [alias] xtask = "run --package xtask --release --"
// xtask/src/main.rs use std::process::ExitCode; fn main() -> ExitCode { let cmd = std::env::args().nth(1).unwrap_or_default(); if cmd != "lint" { eprintln!("usage: cargo xtask lint"); return ExitCode::from(2); } let mut errors = 0; for entry in walkdir::WalkDir::new("crates") { let entry = entry.expect("walkdir"); if entry.path().extension().is_some_and(|e| e == "rs") { for d in my_lint::lint_file(entry.path()).expect("lint") { println!("{}:{}: [{}] {}", d.file.display(), d.line, d.rule, d.message); if matches!(d.severity, my_lint::Severity::Error) { errors += 1; } } } } if errors > 0 { ExitCode::from(1) } else { ExitCode::SUCCESS } }
build.rs で cargo build 時にも走らせる
「絶対 commit させない」 を強制したいなら、 build.rs で同じ lint を呼んで panic!() で止めます。
// build.rs fn main() { println!("cargo:rerun-if-changed=src"); let mut errors = 0; for e in walkdir::WalkDir::new("src") { let e = e.expect("walkdir"); if e.path().extension().is_some_and(|x| x == "rs") { println!("cargo:rerun-if-changed={}", e.path().display()); for d in my_lint::lint_file(e.path()).expect("lint") { println!("cargo:warning={}:{}: [{}] {}", d.file.display(), d.line, d.rule, d.message); if matches!(d.severity, my_lint::Severity::Error) { errors += 1; } } } } if errors > 0 { panic!("ドメイン lint: {errors} 件の error を解消するまでビルドできません"); } }
build.rs は 強い ですが、 cargo check が遅くなる副作用があるので、 開発体験を見ながら判断します。 個人的には xtask + CI step で十分なケースが多く、 build.rs は致命的なドメイン規約だけに絞ることが多いです。
CI step
GitHub Actions ならこれだけ。
- name: domain lint run: cargo xtask lint
PR に lint 結果が必ず出るので、 レビュー前に弾けます。
段階導入のロードマップ
bool-option-pair を一気に error にすると、 既存違反が大量に出てビルドが通らない状況になりがちです。 段階を踏みます。 すべて .rbp-lint.toml のルール severity を切り替えるだけで実現できます 。
| フェーズ | .rbp-lint.toml の設定 |
目標 |
|---|---|---|
| 1. 着地 | bool-option-pair = "note" |
既存違反を可視化する。 ビルドは止めない |
| 2. 新規ガード | bool-option-pair = "warning" + 既存違反箇所に // rbp-lint-allow: を貼る |
新規違反だけ警告。 既存はコメントで明示的に「保留」と記録 |
| 3. 全違反禁止 | bool-option-pair = "error" + suppression コメントは reason 必須 |
違反 = ビルド不可。 suppression は理由付きで残す |
phase 2 の発想は 「--baseline=main で git diff から新規違反だけ抜く」 と同じことを、 suppression コメントを許可リストとして使って静的に表現する ことで実現します。 既存違反 1 件ごとに // rbp-lint-allow: bool-option-pair (reason: 旧スキーマ互換) を貼る作業は、 コードレビュー上 「ここは技術的負債である」 が永続化される副産物が大きい。 純粋な baseline ファイル方式と違って、 負債の理由がコードに残ります 。
phase 3 に到達したら、 bool-option-pair は 書けないドメイン規約 になります。 そこで初めて、 古いコードの enum 化を 1 ファイルずつ進められます。
実運用上の落とし穴 — 数値で測る
理論だけで終わらせないために、 手元の tools/rbp-lint (今回紹介した 21 ルール) を実コードに当てた数字を出します。
| 対象 | files | 実時間 (release ビルド) | 診断数 | コメント |
|---|---|---|---|---|
tools/rbp-lint/src (lint 自身) |
28 | 0.38s | 15 | うち 9 件は lint 自身のパターン定義文字列 が hardcoded-secret に引っかかった偽陽性 |
samples/rust-types-as-walls |
48 | 1.09s | 135 | examples/ の println! で debug-print が大量発火 |
samples/idiomatic-rust-2024 |
17 | <0.01s | 44 | キャッシュ後再実行 |
学べることは 3 つ。
性能は CI 用途には十分。 cold で 1 ファイル 13–22ms。 千ファイルでも 20 秒程度です。 ただし rust-analyzer のリアルタイム保存ループ (300ms 想定) に混ぜるには重いので、 build.rs で全部走らせるのは避ける のが無難。 致命級だけ build.rs、 通常は xtask + CI、 が現実解です。
lint 自身がいちばん最初の偽陽性源。 hardcoded_secret.rs には "sk-", "AKIA" のような検出対象パターンを文字列リテラルとして書く必要がありますが、 これが 自分の lint で error 級違反として検出される という鏡像が起きました。 解決は 1 行: その lint ファイルの先頭に // rbp-lint-allow: hardcoded-secret (reason: パターン定義そのもの) を入れるだけ。 他の偽陽性 (CLI の println!, 学習用 example の panic! など) も同じく .rbp-lint.toml の [paths] exclude か [rules] debug-print = "off" で潰せます。 suppression と config が無いと lint は実運用で死にます。
ドメインに合わせて lint 数を絞ること。 21 ルール全部 on で example 48 ファイルから 135 件の診断は、 シグナル/ノイズ比が悪い。 プロダクト本体ではなく example だから出てるノイズです。 自分のコードベースで意味のあるルールを 5–10 個に絞り、 残りは off、 が運用の落とし所。 「全部入れる」 は誰も喜びません。
ra_ap_syntax のバージョン揺れ
ブログの例は ra_ap_syntax = "0.0.331" で書いてあります。 rust-analyzer に追従する関係で minor で API が変わる ので、 lint プロジェクトでは:
Cargo.lockを 必ずコミット する (binary crate 扱い)- 上げるときはまず
cargo testで AST cast の壊れを検出 - CI に「依存固定検査」 (
cargo update --dry-runで意図しない上昇を弾く) を入れる
これを忘れると、 ある日 PR が落ちたとき 「自分のコードは何も変えてないのに lint が通らない」 という事故になります。
cargo:warning= の落とし穴
build.rs で cargo:warning=... だけ書くと 黄色く出るだけで cargo build は止まりません。 「絶対 commit させない」 を実現したいなら必ず panic!() を呼ぶ必要があります。 ブログの build.rs サンプルにも入れていますが、 ここを忘れて運用 1 ヶ月後に発覚するパターンが定番なので、 統合した直後に わざと違反を入れて build が落ちることをテスト で確認するところまでセットです。 phase 1 と phase 2 (clean) の双方向検証を、 cargo build --release を CI で回す形で残しておくと安全です。
ドメイン語彙を lint に育てる
bool-option-pair を入れて運用していると、 似たパターンの違反が見えてきます。
pub *_id: String/pub *_id: u64の raw identifier (UserId/OrderIdの取り違え予備軍)pub status: Stringの stringly typed status (enum で潰すべき)pub enum *Errorで#[non_exhaustive]がない (SemVer 互換が壊れる)
これらを 1 つずつ lint にすると、 コードレビューの目視チェックリストがコードに昇格 します。 レビューで毎回同じ指摘を書いている、と思ったら lint 化のタイミングです。
ドメインに長く居るほど、 「このパターンが出たら危ない」 という嗅覚が増えます。 その嗅覚を コードに固定する のが、 自家製 lint の最大の価値です。 メンバー交代でも腐らない、 AI が書いた PR でも止まる、 type system の続きにある防衛線として動きます。
手元の tools/rbp-lint には bool-option-pair / raw-id-field / status-string-field / non-exhaustive-pub-error の 4 つを実装してあります。 全部この記事と同じパターンで書けます。
まとめ
- 型で illegal state を消した上に、 lint で 「規約違反」 もコンパイルエラーにする
rowan(ra_ap_syntax) で書けば、 cargo check に乗らない規約も静的検出できるxtask+ CI step で常時走らせる。 致命ならbuild.rsでcargo buildも止める- suppression コメント +
.rbp-lint.tomlを最初から入れる。 偽陽性の逃げ場が無いと lint は実運用で死ぬ - いきなり
errorにせず、note → warning + suppression コメント → errorで段階導入する - レビューで毎回出る指摘は lint に昇格させる。 ドメイン語彙が そのまま壁になる
型と lint は、 同じ 「壁を築く」 行為の表裏です。 型で書けないものを止め、 lint で書けるが書いてはいけないものを止める。 両方を持つと、 ドメインモデルは AI が書いた PR にも壊されません。 そして 「許す」 ことを reason: ... 付きで残せる第三の道を持っておけば、 厳しすぎず緩すぎずの実運用に着地できます。
関連
- リポジトリ:
rust-analyzer/rowan/ra_ap_syntax - "Make illegal states unrepresentable" (Yaron Minsky) / Scott Wlaschin "Designing with Types"