開発チームのコードレビューをしていて、最も議論になり、かつプロジェクトの寿命を左右するのが「関数の戻り値の型を明示すべきか、それともTypeScriptの型推論に委ねるべきか」という問題だ。
「面倒だから推論させよう」「いや、ドキュメント代わりになるから全てに書こう」。
こうした表面的な議論で型注釈の有無を決めているうちは、TypeScriptの本質を捉えているとは言えない。
コンパイラは、あなたが書いたコードのAST(抽象構文木)を走査し、血眼になって型を計算している。型推論と明示的な型注釈のトレードオフを誤ると、「コンパイル速度の低下」「意図しない型の広がり(Type Widening)」「リファクタリング地獄」という3つの悪夢が同時に襲ってくる。
テクニカルリードとして、この永遠の問いにコンパイラの挙動の裏側から終止符を打とう。
—
1. 型推論の罠:なぜ「すべて推論に任せる」と破滅するのか
TypeScriptの型推論は強力だ。しかし、強力であるが故に、開発者の意図を置き去りにして「広すぎる型」を推論してしまうことがある。
特に危険なのが、関数の戻り値の型を推論に任せた結果、内部実装の変更が外部への公開APIの型破壊(Breaking Change)につながるケースだ。
悪臭を放つコード:推論に依存した脆い設計
// ❌ 良くない例:戻り値を完全に推論に任せている
export async function fetchUserProfile(userId: string) {
const response = await fetch(`/api/users/${userId}`);
const data = await response.json();
// 内部的なフラグやメタデータを追加して返しているつもり
return {
id: data.id,
name: data.name,
role: data.role ?? ‘GUEST’, // ここでデフォルト値をフォールバック
permissions: data.role === ‘ADMIN’ ? [‘read’, ‘write’, ‘delete’] : [‘read’],
};
}
この関数自体はエラーなくコンパイルされ、戻り値の型は自動的に決定される。しかし、以下の問題が生じる。
1. バグの隠蔽: `data.role` が予期せぬ値(例: `”MODERATOR”`)だった場合、`role` の型は `string` に広がり(Type Widening)、呼び出し側で思わぬバグを生む。
2. 隠れた破壊的変更: 将来、この関数の内部で `lastLoginAt: new Date()` をこっそり追加したとする。意図せず戻り値のオブジェクトの型が変わり、これを消費しているフロントエンドのコンポーネントで予期せぬ再レンダリングや型エラーが連鎖する。
3. 境界の喪失: モジュール境界(Exportされる関数)において、型注釈がないことは「契約書のない貿易」と同じだ。
—
2. 明示的な型注釈が生む「堅牢性」と「コンパイルの最適化」
モジュール境界、特に他のコンポーネントやAPIクライアントから呼び出される関数の戻り値には、必ず明示的な型注釈(ReturnType)を付与すべきである。
プロダクションコード例:境界を守る堅牢な設計
// ✅ 優れた例:明示的な戻り値の型定義と、型アサーション・ガードの活用
export type UserRole = ‘ADMIN’ | ‘MODERATOR’ | ‘GUEST’;
export interface UserProfile {
readonly id: string;
readonly name: string;
readonly role: UserRole;
readonly permissions: readonly string[];
}
/
- ユーザープロフィールを取得する
- @throws {NetworkError} 通信失敗時
/
export async function fetchUserProfile(userId: string): Promise
const response = await fetch(`/api/users/${userId}`);
if (!response.ok) {
throw new Error(`Failed to fetch user: ${response.statusText}`);
}
const data = await response.json();
// 実行時バリデーション(あるいはZodなどのスキーマ検証)を通過したデータを返す
return {
id: String(data.id),
name: String(data.name),
// 厳格に型を絞り込む(Type Narrowing)
role: isValidRole(data.role) ? data.role : ‘GUEST’,
permissions: getPermissionsForRole(data.role),
};
}
function isValidRole(role: unknown): role is UserRole {
return role === ‘ADMIN’ || role === ‘MODERATOR’ || role === ‘GUEST’;
}
function getPermissionsForRole(role: unknown): readonly string[] {
switch (role) {
case ‘ADMIN’:
return [‘read’, ‘write’, ‘delete’] as const;
case ‘MODERATOR’:
return [‘read’, ‘write’] as const;
default:
return [‘read’] as const;
}
}
なぜこのアプローチが圧倒的に優れているのか?
1. コントラクトの明確化: 呼び出し手は、関数の実装を見に行かなくても `Promise
2. IDEの高速化(パフォーマンス): TypeScriptコンパイラは、明示的な型注釈がある関数について、関数の内部実装を深く再帰的に型推論する必要がなくなる。これが大規模プロジェクトにおける型チェックの高速化(Incremental Compilationの効率化)に直結する。
3. `readonly` によるイミュータビリティの保証: 型注釈と同時に `readonly` を付与することで、意図しないオブジェクトの書き換えをコンパイルタイムで防ぐ。
—
3. あえて「推論に任せるべき」例外的なケース
すべての関数に戻り値を書けばいいかというと、そうではない。以下のケースでは、明示的な型注釈はむしろ悪影響を及ぼす。
① 内部ヘルパー関数やカリー化関数(ローカルスコープ)
モジュール外に公開されない(exportされない)プライベートな関数や、高度なジェネリクスを伴うユーティリティ関数は、型推論に任せたほうが柔軟性が高い。
// ❌ 悪手:無駄に型を縛ることでジェネリクスの推論を破壊する
function mapArray
return arr.map(fn);
}
// ⭕️ 適切:推論に任せることで、コンパイラが最適なタプル型などを維持できる
const createTuple =
const tuple = createTuple(1, ‘string’, true); // 推論結果: [number, string, boolean]
// ここに明示的な戻り値 `unknown[]` などを書くと、タプルの精密な型が失われてしまう。
② Reactのカスタムフックやコンポーネントの内部ロジック
Reactの `useState` や `useMemo` と連携する小さなロジックでは、型推論に任せた方がコードが冗長にならず、型のズレを防げる。
—
4. テクニカルリードとしての結論:使い分けの指針
コードレビューの現場において、以下の基準をチームの共通認識として徹底してほしい。
| 観点 | 明示的な型注釈を書くべき | 型推論に任せるべき |
| :— | :— | :— |
| スコープ | `export` される関数、API層、パブリックなHooks | 内部ヘルパー関数、ファイル内完結の関数 |
| 目的 | 契約(Contract)の固定、依存関係の切断、型チェックの最適化 | 柔軟性の維持、複雑なジェネリクスの伝搬 |
| 保守性 | 高(内部変更が外部に漏れない) | 中(内部変更に伴い型が自動追従する) |
「公開境界(Public Boundary)には明示的な型を。内部実装(Internal Implementation)には推論の恩恵を。」
この原則を死守するだけで、あなたの書くTypeScriptコードベースは、大規模化しても決して崩壊しない、美しく堅牢な城塞へと進化するはずだ。