この記事で扱う話
前回の記事では、非決定論的な AI という生産者を決定論的な検問所でふるい分ける話をしました。体温計・はかり・タイマーのたとえを使いましたね。
この記事では、そのうち 型チェックという検問所ひとつ を拡大して見ていきます。ただ、ここには落とし穴がひとつあります。
型が複雑だからといって安全なコードではありません。
AI に「型を安全に書いて」と頼むと、精巧に見える結果が返ってきます。conditional type が入れ子になり、mapped type が回り、infer が入っています。ところが開けてみると、こういうものが混ざっています。
- generic の推論の権限が複数の引数に散らばって、union が静かに
stringへ広がる - conditional type が意図せず union に分配されたり、
neverを誤って処理する - mapped type が
readonlyや optional の印を失う - ヘルパーレベルのテスト(
Equal<A, B>)は通るのに、実際の公開呼び出しは誤った値を受け入れる any、アサーション、@ts-ignore、skipLibCheckで型エラーを隠す- discriminated union を作っておいて、非同期の順序問題まで解決したと報告する
どれも コンパイルは通ります。 だから「型チェック通過」という信号だけでは何もわかりません。
この記事は、私が使っている frontend-oracle-design ワークフローがこの問題をどう扱うか、そしてその方法を他の場所にどう応用できるかをまとめたものです。
まず: 検問所は合格印ではなくフィルターです
先に用語をひとつ整理しておきます。健全性(soundness) とは「型検査を通ったなら、その種類のランタイムエラーは絶対に起きない」という性質です。
TypeScript は 意図的に健全ではありません。 バグではなく設計上の判断です。
const dogs: Dog[] = [new Dog()];
const animals: Animal[] = dogs; // 通る
animals.push(new Cat()); // 通る
dogs[1].bark(); // 💥
配列を共変として扱うこと、メソッドの短縮記法に二変性の例外を設けること、余剰プロパティチェックをオブジェクトリテラルにだけ適用すること — どれも実用性のために受け入れた穴です。厳格にすると実際のコードのほとんどが通らなくなりますから。
なのでこう整理します。
コンパイル通過は安全の証明ではなく、安くて決定的なフィルターです。
安いというのは人がレビューするよりずっと速いという意味で、決定的というのは 同じコード・同じコンパイラ・同じ設定なら常に同じ判定 が出るという意味です。この二つの性質が、AI の成果物をふるい分けるのにちょうど必要なものです。
目標を書き直すとこうなります。AI の生成そのものを決定論的にするのは不可能です。その代わり、AI が作った複数の候補を同じ基準で通過・拒否させる ことはできます。生成の決定性ではなく 受け入れ判定の決定性 です。
哲学 1 — 何が所有者なのかを確定させます
型がすべてを引き受けると考えた瞬間から、嘘の報告が始まります。だからまず所有権を分けます。
| 対象 | 所有者 |
|---|---|
| 値・Props・状態の組み合わせ・入出力の関係 | 型 |
| API・ストレージ・URL・メッセージのような外部入力 | unknown から始める ランタイムパーサー |
| 観察可能なプロダクトの挙動 | テスト |
| 順序の逆転・重複送信・retry・アンマウント後の到着 | abort signal・重複防止ガード・冪等キー・サーバー検証 |
| 同じプロンプトでの生成の再現性 | モデルとプロバイダ — このワークフローは保証しません |
ここで一番大事な行が 4 行目です。
時間軸は型では証明できません。
union を作ったからといって「順序問題は解決済み」と書いたら、それは嘘です。リクエスト A が先に出たのにレスポンス B が先に到着する状況は、型システムには見えない領域です。それは abort signal やリクエスト ID の比較のようなランタイムの仕掛けが防ぎます。
一行にまとめるとこうです。
type-validはbehavior-correctの同義語ではありません。
型が通ったことを、挙動が正しいという意味で報告するのは、それ自体が欠陥です。そしてこれは AI が本当によくやる報告です。型を直しておいて「安全に処理しました」と言いますから。
型で捕まえられずランタイムで防いだ項目は 必ず決定の記録に残します。 残さないと、次の人が「ここは型が守ってくれるんだな」と思ってその防御を消してしまいます。
哲学 2 — はしごで過剰設計をふるい落とします
AI は求めれば求めるほど精巧な型を作ります。問題は 精巧さが目標ではない ということです。
なのではしごを使います。手前の段で実際の誤用が塞がるなら、後ろの段は使いません。
1. API を用意しない・API を分割することで、誤った組み合わせを作れなくします
2. schema・config・as const の値から typeof・keyof・indexed access・satisfies で派生させます
3. 組み込みユーティリティを使います(Pick・Omit・Extract・Exclude・Parameters・ReturnType・Awaited・NoInfer・Record)
4. すでにインストール済みのライブラリの型を再利用します
5. 代表的な呼び出し箇所で自動推論される最小の generic を使います
6. それでも関係が閉じないなら、mapped・conditional・template-literal・recursive を types/internal に隔離します
1 段目が一番強力なのに、一番よく忘れられます。もっとも安全な API は、そもそも誤って使う方法が存在しない API です。
同じ問題を 6 段目・3 段目・1 段目で解いてみる
言葉だけだと掴みにくいので、実際によく出てくる状況ひとつで見てみます。
カレンダーコンポーネントがあります。
modeがsingleなら日付ひとつ、rangeなら期間、multipleなら日付の配列を扱います。mode によってvalueとonChangeの型が変わらないといけません。
AI が持ってくる答え(6 段目 — conditional type)
type CalendarMode = "single" | "range" | "multiple";
type CalendarValue<M extends CalendarMode> = M extends "single"
? Date | null
: M extends "range"
? DateRange | null
: M extends "multiple"
? readonly Date[]
: never;
type CalendarProps<M extends CalendarMode> = {
mode: M;
value: CalendarValue<M>;
onChange: (value: CalendarValue<M>) => void;
};
動作はします。ところがコストが付いてきます。M が裸の型パラメータなので、union が入ってくると 分配 されて意図しない結果が出ますし、最後の : never の枝がいつ引っかかるのか誰にもわかりません。そして型エラーが出たとき、利用側が見るメッセージはこうです。
Type 'Date' is not assignable to type 'CalendarValue<M>'.
Type 'Date' is not assignable to type 'M extends "single" ? Date | null : ...'
一段下りた答え(3 段目 — lookup map + indexed access)
conditional type がやっていることは、結局「mode の名前で型をひとつ選ぶこと」だけです。なら、ただマップを作ればいいわけです。
type CalendarValueByMode = {
single: Date | null;
range: DateRange | null;
multiple: readonly Date[];
};
type CalendarMode = keyof CalendarValueByMode; // 'single' | 'range' | 'multiple'
type CalendarProps<M extends CalendarMode> = {
mode: M;
value: CalendarValueByMode[M];
onChange: (value: CalendarValueByMode[M]) => void;
};
同じ関係を keyof と indexed access の二つで表現しました。分配の落とし穴がなく、mode の一覧がマップから派生するので 二か所が食い違いようがなく、新しい mode を追加するにはマップに一行入れるだけで済みます。エラーメッセージも Type 'Date' is not assignable to type 'DateRange | null' と読めます。
ここで 誤用が実際に塞がりました。 なら 6 段目は使いません。
<Calendar mode="range" value={new Date()} onChange={...} />
// ~~~~~ Type 'Date' is not assignable to 'DateRange | null'
さらに下りた答え(1 段目 — API を用意しない・分割する)
ところが、もう一歩聞いてみます。プロダクトは三つの mode を全部使いますか?
multiple を誰も使わないなら、それは「型で塞ぐ対象」ではなく そもそも存在すべきでないもの です。そして single と range がキーボード操作や内部の状態管理まで違うなら、ひとつのコンポーネントにまとめたこと自体に無理があります。
// mode prop そのものが消えます
export function CalendarSingle(props: {
value: Date | null;
onChange: (value: Date | null) => void;
}) { /* ... */ }
export function CalendarRange(props: {
value: DateRange | null;
onChange: (value: DateRange | null) => void;
}) { /* ... */ }
generic がなくなりました。誤った組み合わせを 表現する文法自体がありません。 CalendarSingle に DateRange を渡すコードは、そもそも書けません。
では、どれを選ぶのか
| 状況 | 答え |
|---|---|
| mode が 値の型だけ を機械的に決める | 3 段目 — lookup map |
| mode ごとに挙動・キーボード操作・状態の寿命が違う | 1 段目 — コンポーネントの分割 |
| プロダクトがその mode を使わない | 1 段目 — 実装しない(API を用意しない) |
判定の問いはこれです。「mode を消したとき、残るのは同じコンポーネントですか?」 同じなら値の型だけが違うということなので 3 段目、違うなら、もともと別のコンポーネントだったということです。
ひとつ注意することがあります。はしごは複数あり、互いに独立しています。
A. 所有権・境界 既存の owner を再利用 → 派生 → API を用意せず不可能に → 外部の値は unknown からパース
B. 状態空間 フレームワークの union を消費 → capability で分割 → union + never → discriminated union → 状態機械
C. API の関係 typeof・as const・satisfies → keyof・indexed access → 組み込みユーティリティ → 関係型 generic → ...
「keyof は discriminated union より必ず後」のような 全体の順序はありません。 軸の違う問題を一列に並べると、とんちんかんな判定が出ます。それぞれのはしごの中でだけ、手前の段から確認します。
そしてはしごの目的が面白いんです。生成結果を同じにすることではなく、不必要に複雑な後ろの段の仕組みを一貫してふるい落とすこと です。AI が 6 段目の conditional type を持ってきたときに「これは 3 段目で塞がります」と毎回同じ根拠で言えるように。
哲学 3 — 判定の問いをひとつに収束させます
このワークフロー全体を貫く問いはたったひとつです。
AI が生成しえた誤ったコードのうち、今は何がコンパイルされないのか?
この問いのいいところは、答えが具体的か存在しないかのどちらかしかない ことです。
「型安全性が向上します」のような答えは答えではありません。「カラムを削除すると、その id でソートしていた 3 か所の呼び出し箇所がコンパイルエラーになります」は答えです。そして後者は そのままテストケースになります。
具体的に答えられない型の複雑さは追加しません。このルールひとつで落ちるものが驚くほど多いです。
設計の前に探す 6 つの地点
何を塞ぐか探すときは、この 6 つを見ます。そして コンパイルされてはいけない誤った使い方を先に書き出します。
| 地点 | 症状 | 候補 |
|---|---|---|
| 値 | 広い string・number・Date | ブランド型、意味を持つ型 |
| 組み合わせ | boolean が複数、排他的な optional Props | discriminated union、union + never |
| 関係 | mode が戻り値の型を決めるのに、型にはその情報がない | generic な lookup map、コンポーネントの分割 |
| パス・キー | route・query key・field path が自由な文字列 | factory、keyof、派生 union |
| 結果 | 成功・失敗・不在・維持・削除がひとつの undefined に | Result、操作の union |
| 拡張 | 利用側が拡張するキーが string で開いている | typed registry、module augmentation |
公開 API なら、そのリストはそのまま .test-d.ts の @ts-expect-error ケースになります。設計段階のメモが検証コードに直接つながるわけです。いくつ書くかは下の応用 1 の境界軸が決めます — 個数を先に決めることはしません。
応用 1 — 証拠パケットを要求します
高度な型を採用するには、6 種類の証拠を一緒に残します。これが「それらしく見える」と「実際に塞がっている」を分ける仕掛けです。
| 証拠 | 基準 |
|---|---|
| positive | 代表的なプロダクトの呼び出し 1 つが 明示的な型引数なしで コンパイルされます |
| negative | この API が 塞ぐ境界の軸ごとに 1 つずつ、それぞれ 1 行の @ts-expect-error |
| edge | 関係のある any・unknown・never・union・readonly タプル・optional だけ |
| mutation | 契約を弱めるとスイートが実際に RED になります |
| runtime complement | URL・ストレージ・API・時間軸はパーサー・ガード・ランタイムテストが証明します |
| soundness gap | overload の最後のシグネチャ、メソッドの二変性のような 残った穴 を書き出します |
positive の証拠がなぜ必要かが面白いところです。正常な呼び出しが型引数なしで推論されないなら、否定テストを全部通しても良い API ではありません。 誰も使わないからです。安全だけど使えない API は、安全でない API と結果が同じです。
mutation の証拠はもっと大事です。契約をわざと広げてみて、テストが赤くなるか確認します。NoInfer<T> を T に変えたり、union を string に広げたり、必須フィールドを optional に変えたり。赤くならないなら、そのテストは何も守っていなかったということです。
@ts-expect-error が @ts-ignore と違う点が、ここで核心になります。エラーが 消えたら、その行が「未使用」(TS2578)として赤くなります。保護がなくなること自体が失敗になる構造です。
否定ケースは境界で選びます
どの誤用を防ぐかは、個数ではなく境界で決めます。「最低 3 つ」のように個数を目標に置くと、人もツールも数を埋めます — タイポを 3 つ書いて 3 つ埋めた、という具合です。3 つとも同じ境界です。
これはランタイムテストで長く使われてきた 境界値分析(BVA) をそのまま型に移したものです。「最低 2 文字」のルールなら 1・2・3 を確認するように、型にも越える境界があります。ただし値の大きさではなく、型の束(lattice)の極端 です。
// 境界: リテラル / 広がった string
const ok: SortKey = "total";
// @ts-expect-error すでに string に広がった値は入れられません
const bad: SortKey = "total" as string;
そしてここでもうひとつ大事なことがあります。BVA の 4 つの軸のうち、型が覆えない軸があります。 時間・順序の境界と副作用の回数の境界は、union をどれだけ精密に組んでも覆えません。型のケースを埋め終えたら、その 2 軸はランタイムテストへ明示的に渡す必要があります。渡さないと「型テストを全部書いた」が「全部検証できた」と読まれてしまいます。
応用 2 — 前提となる環境をリポジトリごとに一度固定します
「コンパイルされない」が tsconfig の関数なら、環境を知らない判定は判定ではありません。
pnpm exec tsc --showConfig # extends チェーンをたどった実効値
pnpm exec tsc --version # lockfile が実際に解決したコンパイラ
ファイルに書かれた値ではなく、実効値が基準です。Playground や最新バージョンでだけ通った結果は証拠になりません。
strict やバージョンが満たされていないなら ここで止まります。 tsconfig をこっそり変えたりしません — リポジトリ全体に波及するポリシー変更なので、人が決めることです。推奨フラグがオフなら、オンにしようと提案しつつ、断られたら 弱くなる契約のリストを書いて進めます。 「これはコンパイルではなく、レビューとテストが捕まえるべきだ」という意味ですから。
応用 3 — よく出てくる嘘の契約をリストで持っておきます
レビューで繰り返し引っかかるパターンがあります。リストで持っておくと判定が速くなります。
Record<K, V>は totality の契約です。 すべての K が存在するという意味です。観測されたキーだけ埋める sparse な結果で、キーが有限の union ならPartial<Record<K, V>>、ID のように開いたドメインならMapです。- type predicate には検査の義務があります。
value is Tは、本体が必須の invariant を実際に検査するときだけ使います。asを包んだ predicate は、コンパイラを騙しているだけです。 - wrapper の戻り値の契約は実行のタイミングに従います。
Parametersは保存してもいいですが、ReturnTypeは同じ呼び出しで値が出てくるときだけです。debounce はvoidで、キャッシュは| undefinedです。 - 余剰プロパティチェックは sanitizer ではありません。 オブジェクトリテラルの代入にしか効きませんし、効いてもランタイムでフィールドを削除してくれません。
- スキーマは境界にだけ置きます。 アプリの内側で生まれる有限の値は
as constで十分で、パーサーはその値がストレージ・URL・レスポンスから 戻ってくる読み取り地点 に付けます。逆にすると、コストだけ増えて、肝心の危ない場所が空っぽになります。
応用 4 — 状態は union を作る前に二度塞ぎます
型の話をしていると、状態を全部 discriminated union にしたくなります。ところが実際のバグはその手前で起きます。
- 派生できるなら保存しません。
itemCount = items.length。重複して保存された状態は、片方だけ更新されます。 - ライブラリがすでに union を所有しているなら、そのまま消費します。 クエリライブラリの
statusはすでに discriminated な契約で、最新の呼び出しを基準にした時間軸の処理まで含んでいます。useStateにコピーすると、1 レンダー遅れて追いかけてくる第二の真実が生まれます。 - それでも残る本物の client 状態だけ union + 意図を表す関数で作ります。
そしてもうひとつ。状態はデータ、action は兄弟です。
// 禁止 — stale closure と偽の retry が同時に生まれます
type DetailState = { status: "loading" } | { status: "failure"; retry: () => void };
// 許可 — 状態はデータ、action は兄弟として
type DetailState = { status: "loading" } | { status: "failure"; reason: LoadFailure };
function useDetail(id: DetailId): { state: DetailState; retry: () => void };
state に保存した関数は、それを作ったレンダーのクロージャに固定されます。そして使えない状態に retry: () => undefined のような no-op を埋めると、UI に嘘の情報を与えることになります。
→ 状態設計のはしご
応用 5 — 契約ファイルの緩和をポリシー変更として扱います
これが実務では一番大事なルールかもしれません。
AI に「型エラーを直して」と言うと、エラーをなくす一番簡単な道を探します。そしてその道はたいてい 契約を広げること です。
.test-d.tsの@ts-expect-errorケースを削除する- 必須フィールドを optional に変える
- union を
stringに広げる @ts-ignoreや double assertion を入れる
どれも青信号にします。そしてどれも保護をなくします。
契約ファイルは検収の信頼の根です。緩和は実装上の判断ではなく、ポリシー変更です。
なので実装の diff が契約を広げていたら、それは「通過」ではなく 人が決めるべき項目 として上がります。要件が実際に変わったのなら根拠を引用して広げればいいですし、ただエラーをなくそうとしただけなら戻します。
レビューで見るリストも一緒に整理しておくといいです。
- 派生できる値を状態として保存 / クエリの状態をローカルな機械にコピー / raw setter の公開
- state に action を保存、使えない状態の no-op な action
- 境界の値をパースせずにアサーション、
anyがアプリケーション層に漏れる - 手前の段で塞がる問題に後ろの段の型、feature コードでの自作の高度なユーティリティ
satisfies・as constをランタイム検証として 報告する- 時間軸の非決定性を型だけで「解決済み」扱いする
逆に 判定の対象ではないもの も決めておきます。状態の名前の好み、reducer か個別ハンドラかという構文の好み、パターンマッチライブラリの好み — こういうものは意見であって欠陥ではありません。この区別がないと、レビューが好みの争いになります。
他の場所に応用するなら
このやり方をそのまま使わなくても、持っていける部品がいくつかあります。
ひとつ。判定の問いをひとつ決めます。 「今は何がコンパイルされないのか」が強力なのは、答えが具体的か存在しないかのどちらかだからです。みなさんのドメインでも、こういう問いをひとつ作れます。「この検証を消したら、どのテストが赤くなりますか?」のようなものです。
ふたつ。所有権の表を作ります。 何を型が、何をパーサーが、何をテストが引き受けるのか書いておくと、嘘の報告が減ります。特に「AI が保証しないもの」を明示する行が大事です。
みっつ。はしごを作ります。 ツールの一覧ではなく 順序 を決めるということです。手前の段で塞がるなら後ろの段を使わない、というルールひとつが、過剰設計をずっとふるい落としてくれます。
よっつ。mutation の検証を習慣にします。 作った防御装置をわざと壊してみて、赤信号がつくか確認します。つかないなら、それは飾りだったということです。
いつつ。緩和を別トラックに分けます。 検証装置を弱くする変更は、機能実装とは別の承認経路を通らせます。
まとめ
今日まとめたことを一行に縮めるとこうです。
型で塞いだという主張は「今は何がコンパイルされないのか」に具体的に答えられるときだけ真であり、その答えはたいてい構文のひとかけらが抜けるだけで、何の警告もなく偽になります。
そしてこのワークフローが実際にやっているのは、安全を証明することではありません。毎回同じ基準で、通過と拒否を再現可能にすること です。AI はこれからも違うコードを出してくるでしょうし、検問所はずっと同じ判定を下せばいいのです。
関連して一緒に読むといい記事です。
- 実装より先に正解の基準をロックします — このワークフローの哲学の全体
- 非決定論的な AI コードを、決定論的な検問所でふるい分けます — 段階ごとの手順