定義
型の境界値分析は、テスト設計の境界値分析(BVA, boundary value analysis)を型レベルに適用する方法です。どの型テストケースを書くべきかを境界で選ぶわけです。
ランタイムテストでは、すでにおなじみの話です。「最小 2 文字」というルールなら 1 文字・2 文字・3 文字を確かめますよね。欠陥は区間の真ん中ではなく、境界に集まっているからです。
型も同じです。ただし型の境界は値の大きさではありません。型の束(lattice)の極端です。never と unknown のあいだ、リテラルと広がった string のあいだ、プロパティの省略と明示的な undefined のあいだ — ここが型の min−1・min・min+1 です。
なぜ必要か
型テストを書くとき最初にぶつかる問いは「何を書けばいいのか」です。そしてここでいちばんよくある答えが、個数で決めることです。「否定ケースは 3 つ書こう」のような。
個数を基準にすると何が起きるかというと、数を埋めにいきます。
// @ts-expect-error 存在しないカラム
const a: SortKey = "ordreNo";
// @ts-expect-error 存在しないカラム(タイポが違うだけ)
const b: SortKey = "ordrNo";
// @ts-expect-error 存在しないカラム(また)
const c: SortKey = "orderNumber";
3 つ埋まりました。ところが三つとも同じ境界を三回さわっただけです。リテラルが string に広がるか、読み取り専用の配列を受け取るか、推論の権限がどこにあるかは、ひとつも見ていません。個数は埋まったのに検出力はそのままです。
境界で選ぶと、この問題が消えます。ひとつの境界に witness ひとペアで足りますし、重なるケースは自然に落ちます。そして何を見なかったかがリストとして残ります。
動作原理
まず: 型が覆う軸と覆えない軸
ランタイムの BVA はふつう四つの軸で見ます。型に移す前に、どこまで移せるのかを先に決める必要があります。
| ランタイム BVA の軸 | 代表的な境界 | 型が覆うか |
|---|---|---|
| 値の境界 | min−1 / min / min+1、空の値、形式 | 覆えます — ただし大きさではなく型の束の極端として |
| 状態の境界 | 各遷移の直後(idle→pending など) | 一部だけ — 「表現できる組み合わせ」まで。実際に遷移が起きるかは見えません |
| 時間・順序の境界 | レスポンスの逆転、アンマウント後の到着 | 覆えません |
| 副作用の回数の境界 | 0 回 / ちょうど 1 回 / 2 回以上 | 覆えません |
下の二行がこのドキュメントでいちばん大事です。時間軸と副作用の回数は型では証明できません。 union をどれだけ精密に組んでも、リクエストが二回出ることは防げません。これはランタイムテストが所有する領域で、型テストを全部書き終えてもこの軸は覆われません。
状態の境界が「一部だけ」なのも同じ理由です。型は { status: 'loading'; error: Error } のようなあり得ない組み合わせを表現不可能にできますが、「失敗した直後に loading が実際に解除されるか」は動かしてみないと分かりません。
型の束の 7 軸
値の境界を型に移すと、これらの軸が出てきます。各軸は境界ひとつで、境界ごとに通過する witness を 1 つ、拒否する witness を 1 つ置きます。
軸 1. union メンバー — 境界は「メンバー / 非メンバー」
type SortKey = "orderNo" | "total";
const ok: SortKey = "total";
// @ts-expect-error 非メンバーは拒否されなければなりません
const bad: SortKey = "userId";
軸 2. literal widening — 境界は「リテラル / 広がった string」
値から型を派生させる API なら必ず見るべき軸です。広がってしまうと、上の軸 1 の保護がまるごと消えるからです。
const cols = defineColumns([{ id: "orderNo" }, { id: "total" }]);
type Key = ColumnIdOf<typeof cols>;
const ok: Key = "total"; // リテラルが生きていてはじめて通ります
// @ts-expect-error すでに string に広がった値は入れません
const bad: Key = "total" as string;
軸 3. optional — 境界は「プロパティの省略 / 明示的な undefined」
exactOptionalPropertyTypes の実効設定によって判定が分かれます。この軸を使う前に設定を確認します。
type Options = { retry?: number };
const ok: Options = {}; // 省略は許可
// @ts-expect-error exactOptionalPropertyTypes のもとでは明示的な undefined が拒否されます
const bad: Options = { retry: undefined };
軸 4. readonly — 境界は「読み取り専用の入力を受け取る / modifier を失う」
入力を変更しない API が readonly T[] と as const タプルを受け取るか、そして mapped type が readonly を失わないかを見ます。
const frozen = [{ id: "orderNo" }] as const;
const ok = defineColumns(frozen); // readonly tuple を受け取れなければなりません
type Mapped = { readonly [K in keyof Config]: Config[K] };
// @ts-expect-error modifier が保存されていれば代入は防がれます
const bad: Mapped = mutableConfig;
軸 5. never・any・unknown — 境界は「束の底 / 穴 / 天井」
分配 conditional が契約であるときだけこの軸を使います。そうでなければ作りません。
type Boxed<T> = [T] extends [string] ? "yes" : "no";
type Distributed<T> = T extends string ? "yes" : "no";
const a: Distributed<never> = "no" as never; // never は分配されると never になります
const b: Boxed<never> = "yes"; // ボックス化すると分配されません
// @ts-expect-error any は両方の分岐を満たすので union になります
const c: Distributed<any> = "yes" as const;
軸 6. tuple arity — 境界は「空タプル / 1 個 / n 個」
契約が実際に長さを区別するときだけ使います。可変長引数の関係を保つ API がここに当たります。
const one = defineRoutes(["/home"]);
const many = defineRoutes(["/home", "/settings"]);
// @ts-expect-error 空のリストはルートがないという意味なので拒否します
const none = defineRoutes([]);
軸 7. 推論の権限 — 境界は「推論に参加する / しない」
NoInfer や const 型パラメータを使うなら、どの引数が権限を持つかが契約です。
function pick<T>(options: readonly T[], fallback: NoInfer<T>): T;
const ok = pick(["a", "b"], "a"); // options からだけ T を推論します
// @ts-expect-error fallback は union を広げられません
const bad = pick(["a", "b"], "z");
実務での適用
三つの手順
ステップ 1 — 軸を選び、選ばなかった軸に理由を書きます。
今回の API が実際に閉じる軸だけを選び、残りはなぜ該当しないのかを一行残します。このリストがそのままレビュー対象になります。
| 軸 | 適用 | 理由 |
| --------------- | ---- | ------------------------------------------ |
| union メンバー | O | ソートキーを閉じた union として公開 |
| literal widening| O | カラム配列から union を派生 |
| 推論の権限 | O | const 型パラメータでリテラルを保存 |
| optional | - | optional なプロパティなし |
| readonly | - | 入力は変更しないが配列を受け取らない |
| never/any/unknown | - | 分配 conditional ではない |
| tuple arity | - | 長さを区別する契約なし |
閉じない軸に witness を埋めるのは、個数埋めと同じです。すべての型に同じチェックリストを貼らないようにします。
ステップ 2 — 軸ごとに witness をひとペア書きます。
通過を 1 つ、@ts-expect-error を 1 つ。そして @ts-expect-error 一行には誤用ひとつだけを入れます。複数入れると、無関係なエラーがひとつ出るだけで通ってしまいます。
// 軸: literal widening
const ok: Key = "total";
// @ts-expect-error 広がった string は拒否 — この行が軸ひとつを所有します
const bad: Key = "total" as string;
そのうえで、正常呼び出しの witness を全体にひとつ置きます。明示的な型引数なしでコンパイルされるかを見るためです。これができないなら、否定ケースを全部通しても使える API ではありません。
ステップ 3 — mutation で生きているかを確認します。
軸ごとに契約をわざと壊してみて、その軸の witness が赤くなるかを見ます。
| 軸 | 壊し方 | 期待 |
|---|---|---|
| literal widening | const 型パラメータから const を消す | その軸の witness が RED |
| 推論の権限 | NoInfer<T> → T | その軸の witness が RED |
| union メンバー | union を string に広げる | その軸の witness が RED |
| optional | exactOptionalPropertyTypes を切る | その軸の witness が RED |
赤くならない軸があれば、その witness は最初から何も守っていなかったということです。
ランタイムに渡すものを一緒に書きます
型の軸を埋め終えたら、覆えなかった二つの軸を明示的に渡します。
- 時間・順序: リクエストの逆転・アンマウント後の到着 → abort signal + ランタイムテスト
- 副作用の回数: 二重送信時にリクエストがちょうど 1 回 → ランタイムテスト
この行がないと、「型テストを全部書いた」が「全部検証された」と読まれてしまいます。それがこの方法でいちばんよくある事故です。
トレードオフ
軸で選ぶとケース数はふつう減ります。 個数基準で 3 つ書いていた API が、軸では 2 つしか出てこないこともあります。最初は手薄に見えますが、検出力はむしろ上がります。重なるケースが抜けて、見ていなかった境界が入るからです。
逆に軸が多い API もあります。7 軸すべてに当たれば witness は 14 個です。これは型が複雑なのではなく、API の表面が広いというサインとして読むのが正しいです。増え続けるならケースを追加するのではなく API を分けます。(ひとつの API の @ts-expect-error が 30 を超えたら、その時点です。)
軸の選択そのものが判断なので、コストがあります。 チェックリストを機械的に全部埋めるより考えることを要求します。そのかわり、その判断がドキュメントに残ってレビューが可能になります — 「この軸はなぜ見なかったのですか」と聞けるようになります。
使ってはいけない場合
- ローカルの状態・内部のヘルパー。 呼び出し箇所がいくつかで、しかも同じファイルにあるなら、軸を検討するコストのほうが得られるものより大きいです。型テスト自体が過剰です。
- ランタイム検証の代わりにしようとすること。 外部入力は軸ではなくパーサーが見ます。テンプレートリテラル型で URL を検証したと報告してはいけません。
- すべての API に 7 軸を全部埋めること。 これがこの方法をいちばん確実に台無しにする道です。軸のリストは選ぶメニューであって、埋めるフォームではありません。
- 環境を知らないまま optional・readonly の軸を使うこと。
exactOptionalPropertyTypesがオフなら、軸 3 の拒否 witness は成立しません。
よくある失敗
- 同じ境界を何度もさわる。 タイポ三つは軸ひとつです。三ケースではなく一ケースとして数えます。
- 閉じてもいない軸に witness を作る。 分配 conditional でもないのに
never・any・unknownを入れると、保守対象が増えるだけです。 @ts-expect-error一行に複数の誤用を詰め込む。 無関係なエラーひとつで通ってしまい、その軸が空っぽなことに気づけません。@ts-ignoreを使う。 エラーが消えても静かなままなので、軸が崩れたことを捕まえられません。つねに@ts-expect-errorです。- 正常呼び出しの witness を忘れる。 拒否だけを確認すると、「何も通さない API」も満点になります。
- mutation 検証をしない。 軸ごとに壊してみなければ、witness が死んでいても分かりません。
- 時間軸・副作用の回数を型で覆ったと報告する。 この二つの軸は型テストが終わってもそのまま残っています。
関連概念
- type-level-testing — witness を実際に実行可能にする道具(
.test-d.ts、@ts-expect-error、mutation) - test-oracle — 「何が正解か」の出どころの問題。型レベルでは「何がコンパイルされてはいけないか」がそのオラクルです
- typescript-environment-contract — optional・readonly の軸の判定が分かれるコンパイラ設定
- derive-types-from-values — literal widening の軸がいちばんよく必要になる場所