定義
偽の型契約とは、コンパイルは通るけれど 実装が実際には保証していないことを約束している 型のことです。
型エラーが出るコードはすぐ直せます。問題はその逆です。型は静かなのに、その型がした約束をランタイムが守らないケースですね。こういう型は安全装置ではなく、偽の安心感を与えます。読む人に「ここは確認済みなんだな」と思わせて、検査を省略させてしまうからです。
原則は一行です。型は実装が実際に保証する範囲までしか約束しない。
なぜ必要か
具体的な事故をひとつ見てみましょう。
function groupByStatus(orders: Order[]): Record<OrderStatus, Order[]> {
const result = {} as Record<OrderStatus, Order[]>;
for (const o of orders) {
(result[o.status] ??= []).push(o);
}
return result;
}
// 消費側
const grouped = groupByStatus(orders);
grouped.refunded.length; // 💥 返金注文が一件もなければ undefined
型は Record<OrderStatus, Order[]> です。これは「すべての OrderStatus キーが結果に存在する」という意味です。ところが実装は観測された状態しか埋めません。型がランタイムより強く語ってしまったわけです。
消費側に落ち度はありません。型があると言ったから、あると思って使っただけですから。責任は偽の契約を書いた側にあります。
こういう契約が危険なのは、エラーが定義側ではなく はるか離れた消費側で 起きるからです。しかもその消費側には何の落ち度もありません。
動作原理
偽の契約はいくつかの定型的な形で現れます。
1. Record<K, V> は totality の契約です
Record<K, V> は「すべての K が存在する」という約束です。実装がそれを守れるときだけ使ってください。
| 実装がやること | キーのドメイン | 正しい型 |
|---|---|---|
| すべてのキーを事前に初期化する | 有限な union | Record<K, V> |
| 観測されたキーだけ埋める(groupBy など) | 有限な union | Partial<Record<K, V>> |
| 観測されたキーだけ埋める | ID のような開いたドメイン | Map<K, V> |
開いたキードメインに Partial<Record<K, V>> をかぶせるのは、Map がすでに与えてくれる V | undefined の参照契約を手で作り直しているだけです。
これは Partial<DomainEntity> を mutation ペイロードに使うなというルールとは 別の問題です。 後者は演算の意味を失う patch であり、前者は一部のキーだけがランタイムに存在するという結果の表現です。両者を同じルールでまとめて禁止するのは誤適用です。
2. type predicate には検査の義務があります
value is T は「この関数が通したなら本当に T だ」という約束です。
// ❌ 何も検査していないのにドメイン型を約束しています
function isUser(v: unknown): v is User {
return typeof v === "object" && v !== null;
}
// ❌ as を包んだだけの predicate
function isUser(v: unknown): v is User {
return Boolean(v as User);
}
// ✅ 単純で正確な narrowing だけ
function isNotNil<T>(v: T | null | undefined): v is T {
return v != null;
}
境界から入ってくる複雑なドメイン型は、predicate ではなく スキーマパーサーが担当します。predicate は isNotNil くらいの確実な判定だけに留めましょう。
3. wrapper の戻り値契約は実行タイミングに従います
呼び出し契約(Parameters)は保存してもいいですが、ReturnType の保存は wrapper が同じ呼び出しで実際に値を返すときだけです。
type AnyFn = (...args: never[]) => unknown;
// debounce・schedule — この呼び出しでは値がありません
type Deferred<F extends AnyFn> = (...args: Parameters<F>) => void;
// キャッシュ — miss なら値がありません
type Cached<F extends AnyFn> = (...args: Parameters<F>) => ReturnType<F> | undefined;
// async wrapper
type Wrapped<F extends AnyFn> = (...args: Parameters<F>) => Promise<Awaited<ReturnType<F>>>;
debounce(fn) が ReturnType<typeof fn> を返すと宣言すると、呼び出し側は値がすぐ来ると信じます。実際に来るのは undefined です。
4. excess property check は sanitizer ではありません
これは本当によく誤解されます。
type PublicUser = { id: string; name: string };
const source = { id: "1", name: "foo", passwordHash: "..." };
const user: PublicUser = source; // ✅ コンパイルは通ります
JSON.stringify(user); // 💥 passwordHash がそのまま出ていきます
余剰プロパティ検査は オブジェクトリテラルを直接代入するときだけ 働きます。変数を代入すると働きませんし、働いたとしても ランタイムでフィールドを削除してはくれません。 型はコンパイル後に消えるからです。
機密フィールドの除去は、ランタイムの projection かパーサーが所有します。
// ✅ 実際に取り除きます
const user: PublicUser = { id: source.id, name: source.name };
5. key remapping の戻り値型はランタイムと同型でなければなりません
関数が実際にはキーを変換していないのに ToCamelCaseKeys<T> のような戻り値型だけ付けると、それは偽の契約です。型の上では userName が見えるのに、ランタイムのオブジェクトには user_name が入っています。
実務での適用
自己点検の質問
新しい型を使う前に、これを問いかけてみてください。
この型がする約束を、実装はすべての経路で守っていますか?
ひとつでも守らない経路があるなら、型を弱くします。強い型 + 弱い実装より、弱い型 + 明示的な検査のほうが常に優れています。
// 弱い型 — 消費側に検査を強制します
function groupByStatus(orders: Order[]): Partial<Record<OrderStatus, Order[]>>;
const grouped = groupByStatus(orders);
const refunded = grouped.refunded ?? []; // 検査が強制されます
satisfies・as const を検証として報告しない
satisfies、as const、型注釈はすべて コンパイル時のツールです。ランタイムのデータを検証したり整形したりはしません。
// ❌ 検証したわけではありません — ただコンパイラを黙らせただけです
const config = JSON.parse(raw) as AppConfig;
// ✅ 実際に検査します
const config = appConfigSchema.parse(JSON.parse(raw));
レビューで見つける方法
コードレビューでこの系統を見つける一番早い方法は、型と実装を並べて反例をひとつ探してみることです。「この関数が空配列を受け取ったら、戻り値はあの型を満たしますか?」の一文でだいたい露わになります。
トレードオフ
弱い型は消費側に負担をかけます。 Partial<Record<K, V>> に変えると、すべての呼び出し側に ?? [] やオプショナルチェーンが付きます。コードは雑然として見えますし、「前はこうじゃなかったのに」という声が出ます。
でもその雑然さこそが正確です。値がないかもしれないという事実がコードに現れただけですから。きれいに見えていた以前のバージョンは、ないかもしれないという事実を隠していたのです。
逆方向のコストもあります。強い契約を守るために実装を直すという選択肢も常にあります。groupBy の結果をすべてのキーで事前に初期化すれば、Record<K, V> は真実になります。キーが有限で数が少なければ、こちらのほうが良いことも多いです。型を弱くするのと実装を強くするのと、どちらが安いかを毎回検討してください。
使ってはいけない場合
この文書のルールを過剰適用してはいけない場面もあります。
- すべての
RecordをPartialに変える。 実装が本当にすべてのキーを埋めるなら、Recordが正確な型です。不要に弱めると、消費側に意味のない null チェックが増えるだけです。 - すべての predicate を禁止する。
isNotNilのように単純で正確な narrowing は、predicate が一番読みやすいです。 as constを一切使わない。as constはリテラル保存のツールであって検証のツールではありません。検証として報告しなければ、いくらでも使って大丈夫です。
よくある失敗
{} as Record<K, V>から始めて一部だけ埋める。 この文書の代表例です。出発点の断言が契約を偽にします。asで API レスポンスをドメイン型に変える。 パースではなくコンパイラの沈黙です。サーバーが形式を変えると、アプリの奥深くで壊れます。- 余剰プロパティ検査がフィールドを消すと信じる。 型はランタイムには存在しません。
Partial<DomainEntity>を mutation ペイロードに使う。undefinedが「維持」なのか「削除」なのかを表現する方法がありません。rename・clear-descriptionのような演算の union に分けましょう。catchの値をanyのように扱う。useUnknownInCatchVariablesがオフだと、捕まえたエラーが検査されないまま流れ回ります。@ts-ignoreや double assertion でエラーを消す。 エラーは消えますが、偽の契約は残ります。
関連概念
- state-modeling-ladder — 状態 union が本当に必要かどうかから判断する順序
- type-level-testing — 契約が実際に何を防ぐのかをコンパイラの証拠として残す
- typescript-variance — TypeScript が意図的に不健全なまま残した場所
- typescript-environment-contract — これらのルールのいくつかはコンパイラフラグが有効でないと強制されません