定義
型レベルテストは、コードを実行して結果を確かめるのではなく、コンパイルが通るか通らないかを確かめるテストです。
普通のテストが「この関数を呼ぶとどんな値になるか」を問うのに対して、型レベルテストは「このコードはそもそも書けるのか」を問います。ファイル拡張子には .test-d.ts(JSX があれば .test-d.tsx)を使い、実行される代わりに型チェッカーが合否を判定します。
なぜ必要か
型を複雑に作り込んだうえで「これで安全です」と言うのは簡単です。問題は、その主張がたいてい検証されないことです。
具体的なシナリオを見てみましょう。カラム配列からソートキーの union を派生させる型を作りました。ところが誰かがリファクタリング中にジェネリック制約を少しいじってしまい、その結果 union が string に広がってしまいました。
プロダクションコードは相変わらず全部コンパイルできます。 columnId: 'ordreNo' のようなタイポも通ってしまいます。保護が消えたことに誰も気づきません。次にタイポをした人が QA で引っかかるか、引っかからなければそのままリリースされます。
型レベルテストはこの沈黙をなくします。判定の問いはひとつだけです。
書けてしまっていた誤ったコードのうち、何がもうコンパイルされなくなりましたか?
この問いに具体的に答えられない型の複雑さは、そもそも入れないほうがいいです。答えられるなら、その答えがそのままテストケースになります。
動作原理
中心になる道具は @ts-expect-error の一行です。これは「次の行では必ず型エラーが出なければならない」という印です。
// @ts-expect-error 存在しないカラム id
const bad: SortKey = "ordreNo";
エラーが出れば、この行がエラーを飲み込んで通過します。ところがエラーが消えると — つまり保護が無効化されると — この行が「使われていない @ts-expect-error」という別のエラー(TS2578)を出して、テストが赤くなります。
保護が消えることがそのまま失敗になる構造です。これが @ts-ignore との決定的な違いです。@ts-ignore はエラーがあってもなくても黙って通してしまうので、何も守れません。
証拠は五種類そろえます
| 証拠 | 基準 | ないと起きること |
|---|---|---|
| positive | 代表的な正常呼び出しが明示的な型引数なしでコンパイルされる | 誰も使わない安全な API になる |
| negative | この API が閉じる境界軸ごとに 1 つずつ、それぞれ一行で書かれている | 何を防いでいるのか分からない |
| edge | any・unknown・never・readonly タプルなどの境界値の挙動 | 境界だけ抜ける穴が残る |
| mutation | 契約を弱めるとテストが実際に RED になる | テストが何も守っていなくても分からない |
| soundness gap | 型で防げなかった穴をドキュメントに書く | 誤った安心感が生まれる |
環境がそのまま判定基準になります
「コンパイルされない」は tsconfig の関数です。strict がオフなら防がれるはずのコードが防がれず、TypeScript のバージョンが低ければ const 型パラメータや NoInfer 自体が使えません。
なので型契約をはじめて作るリポジトリでは、tsc --showConfig で確認した実効設定と、lockfile が実際に resolve したコンパイラのバージョンをまず記録しておきます。Playground や最新バージョンでだけ通った結果は証拠になりません。
ケースを選ぶ基準
どのケースを書くかは、個数ではなく境界で決めます。「否定ケースを 3 つ」のように個数を目標にすると、同じ境界を三度つついたケースが 3 つ積み上がります。
境界とは型の格子(lattice)の極端です — union のメンバーと非メンバー、リテラルと広がった string、プロパティの省略と明示的な undefined といった場所ですね。今回の API が実際に閉じる境界ごとに、通過する witness 1 つと @ts-expect-error 1 つを置き、閉じない境界には作りません。
選び方は別のドキュメントが扱います。
実務での適用
// columns.test-d.ts
import { defineColumns, type ColumnIdOf } from "./columns";
const cols = defineColumns([
{ id: "orderNo", header: "注文番号" },
{ id: "total", header: "合計" },
]);
type SortKey = ColumnIdOf<typeof cols>;
// ── positive: 正常な呼び出しが型引数なしで推論されます
const ok: SortKey = "total";
// ── negative: 軸ごとにひとつ、一行に誤用ひとつ
// @ts-expect-error タイポ — 存在しないカラム
const typo: SortKey = "ordreNo";
// @ts-expect-error 別の表のカラム id
const foreign: SortKey = "userId";
// @ts-expect-error 広い string は許可されません
const wide: SortKey = "total" as string;
mutation 検証の手順
テストを作ったら終わりではありません。そのテストが本当に生きているかを確認する必要があります。
- 契約をわざと弱めます。
<const T extends ...>からconstを消すか、NoInfer<T>をTに戻します。 - 型チェックを走らせます。テストが RED になるはずです。
- 元に戻して GREEN を確認します。
RED にならなければ、そのテストは何も守っていません。この手順はコードではなく判断なので、一度やって結果を記録しておけば十分です。
CI に実際に含まれているかの確認
.test-d.ts ファイルは実行されないので、tsconfig.json の include から外れていると誰もチェックしません。ファイルはあるのにチェックはされていない状態は、けっこうよくあります。
# このファイルがチェック対象に入っているか確認
pnpm exec tsc --noEmit --listFiles | grep test-d
トレードオフ
得られるものは、保護が消えたときに騒がしく失敗してくれることです。型リファクタリングでいちばん怖いのは「エラーが出ないまま保護だけ消えること」ですが、それを防いでくれます。
払うコストは二つあります。
第一に、型チェックの時間が増えます。 複雑な型を複数の角度からチェックするからです。再帰型が混ざると、体感できるほど遅くなることもあります。
第二に、テスト自体が保守対象になります。 API を意図的に広げたときに @ts-expect-error が赤くなりますが、これが本当の回帰なのか意図した変更なのかは人が判断しなければなりません。
ただしこの二つ目のコストは、実は利点でもあります。契約ファイルを緩めるのは実装上の決定ではなく、ポリシーの変更だからです。それがレビューに現れるのは正しいことです。
使ってはいけない場合
- アプリ内部の短い型。 呼び出し箇所がいくつかで、しかも全部同じファイルにあるなら、型テストを使うコストのほうが得られるものより大きいです。
- 動作検証の代わりにしようとすること。
type-validはbehavior-correctではありません。型が通ったから動作も正しいと報告するなら、それは誤った報告です。 - 非同期の順序を証明しようとすること。 リクエスト順序の逆転、二重送信、アンマウント後の到着といった時間軸の問題は型では証明できません。 union をひとつ作って「順序問題は解決」と書くのは嘘です。これはランタイムテストが所有します。
- 外部入力の検証の代わりにしようとすること。 API レスポンスや
localStorageの値は、型アサーションではなくランタイムのパーサーが検査します。asやsatisfiesを検証として報告してはいけません。
よくある失敗
- 個数を基準にケースを選ぶ。 同じ境界を三度つついたケースが 3 つ積み上がります。境界で選ぶ方法は type-level-bva が扱います。
@ts-expect-error一行に複数の誤用を詰め込む。 そうすると無関係なエラーがひとつ出るだけで通ってしまいます。誤用ひとつにつき一行です。@ts-ignoreを使う。 エラーが消えても静かなままなので、何も守りません。つねに@ts-expect-errorを使います。- mutation 検証をしない。 テストが生きているかを一度も確認しなければ、最初から死んでいても分かりません。
- 正常呼び出しのテストを忘れる。 誤用は防げても正常な呼び出しが型引数なしで推論されないなら、それは良い API ではありません。誰も使いません。
.test-d.tsがチェック対象に入っていない。 ファイルはあるのに CI が見ていない状態です。実行されるテストではないので、目にも留まりません。- 実装の diff が
@ts-expect-errorを消して GREEN にする。 これは通過ではなく、契約をこっそり広げただけです。レビューで別途扱うべきです。
関連概念
- type-level-bva — どのケースを書くかを境界で選ぶ方法
- test-oracle — 「何が正解か」の出どころの問題。型レベルでは「何がコンパイルされてはいけないか」がそのオラクルです
- mutation-testing — テストが実際に欠陥を捕まえるかを逆から検証する、同じ発想
- derive-types-from-values — 派生が静かに無効化されるのを捕まえる、代表的な適用先