【実務・中級編】TypeScriptの型システムにおける「トップ型」と「ボトム型」の活用 – TypeScript コア・型システムの基礎解析バイブル

TypeScriptを極限まで硬くする:`unknown`・`never`が導く、コンパイル時エラーゼロの要塞設計

コードレビューをしていて、最もエンジニアの「思考の怠慢」を感じる瞬間はどこか知っているか?
それは、APIからのレスポンスやcatch節の型に、思考停止で `any` が貼られているコードを見た時だ。あるいは、網羅性検証(Exhaustiveness Checking)を怠ったがために、switch文に新しいケースを追加した瞬間にサイレントバグを生み出すコードを見た時だ。

TypeScriptの型システムの本質は、「実行時エラーの可能性を、可能な限りコンパイル時に削ぎ落とすこと」にある。そして、その防衛網の最前線に立ち、型システムの屋台骨を支えているのがトップ型(`unknown`)とボトム型(`never`)だ。

今回は、InterfaceやType Aliasの深淵において、これらトップ型とボトム型をどう手なずけ、フロントエンドの非同期API連携やコンポーネント設計を「絶対に破綻しない要塞」へと昇華させるか。その実践的な設計パターンをコードレビューの視座で伝授しよう。

—

1. トップ型 `unknown` vs `any`:型安全の放棄と、真の「安全な動的処理」

まず大前提として、`any` はTypeScriptの型システムに対する「敗北宣言」だ。`any` を使った瞬間、その変数はコンパイラの監視網から外れ、どのような危険な操作も許容される。

一方、`unknown` は「何が来るか分からないが、型安全に扱うまでは一切の操作を禁止する」という厳格なトップ型である。

プロダクションコード:APIクライアントの境界防御

外部APIやfetchの戻り値は、実行時には何が入っているか分からない。ここで `unknown` をインターフェースの境界(Boundary)として活用する。

/

  • 外部APIからのレスポンスを表すジェネリック型。
  • データ本体は未知(unknown)として受け取る。

/
interface ApiResponse {
readonly status: number;
readonly data: T;
readonly error?: string;
}

/

  • ユーザー情報の厳密なインターフェース

/
interface User {
readonly id: string;
readonly name: string;
readonly email: string;
}

/

  • 実行時型ガード(User Type Guard)
  • unknownで受け取ったデータを、安全にUser型へ窄める(Narrowing)

/
function isUser(value: unknown): value is User {
return (
typeof value === ‘object’ &&
value !== null &&
‘id’ in value &&
typeof (value as Record).id === ‘string’ &&
‘name’ in value &&
typeof (value as Record).name === ‘string’ &&
‘email’ in value &&
typeof (value as Record).email === ‘string’
);
}

/

  • 堅牢なAPIフェッチ関数

/
async function fetchUser(url: string): Promise {
const response = await fetch(url);
const json: unknown = await response.json(); // 生のデータは必ずunknownで受ける

// 境界でのバリデーション(ここで型を絞り込む)
if (isUser(json)) {
return json; // この瞬間、unknownからUserへ昇格する
}

throw new Error(‘APIレスポンスの構造がUser型と一致しません。’);
}

【チーフアーキテクトの視点】
`unknown` を使ったデータは、型ガードを通さない限りプロパティアクセスすらできない。これにより、バックエンドの仕様変更や不正なペイロードによるフロントエンドのクラッシュ(`TypeError: Cannot read properties of undefined`)を、コンパイル時および境界検知のレベルで完全に封じ込めることができる。

—

2. ボトム型 `never`:すべての型のサブタイプが持つ「絶対的無」

次に、型システムの底に位置するボトム型 `never`についてだ。
`never` とは、「値が存在しないこと」を表す型である。どの型も `never` に代入することはできないが、`never` はあらゆる型のサブタイプであるため、どんな型にも代入できる。

この性質を利用して、型安全なエラーハンドリングと、コンパイル時網羅性チェックを実現する。

プロダクションコード:Redux/Reducerや状態機械における網羅性チェック

フロントエンドの複雑なUI状態(ローディング、成功、エラー、初期状態など)をUnion型で表現する際、`never` による網羅性保証は必須のテクニックだ。

// ステートの定義(Discriminated Union)
type AsyncState =
| { readonly status: ‘IDLE’ }
| { readonly status: ‘LOADING’ }
| { readonly status: ‘SUCCESS’; readonly data: T }
| { readonly status: ‘FAILURE’; readonly error: Error };

/

  • 網羅性チェック(Exhaustiveness Checking)用ヘルパー関数
  • もしすべてのケースがハンドリングされていれば、ここに到達することはない。
  • 到達した場合、TypeScriptのコンパイラが型の不一致(neverではない)を検知しエラーを吐く。

/
function assertNever(x: never): never {
throw new Error(`予期せぬステートが検出されました: ${JSON.stringify(x)}`);
}

/

  • UIコンポーネントでの状態描画関数

/
function renderContent(state: AsyncState): string {
switch (state.status) {
case ‘IDLE’:
return ‘ボタンを押してデータを取得してください。’;

case ‘LOADING’:
return ‘読み込み中…’;

case ‘SUCCESS’:
return `データ: ${JSON.stringify(state.data)}`;

case ‘FAILURE’:
return `エラー発生: ${state.error.message}`;

default:
// ここで state は never 型に収束しているはず。
// もし AsyncState に新しいステート(例: ‘REFRESHING’)が追加され、
// このswitch文でハンドリングし忘れると、ここでコンパイルエラーが発生する!
return assertNever(state);
}
}

【チーフアーキテクトの視点】
もし将来、プロダクトの仕様変更で `AsyncState` に `{ readonly status: ‘REFRESHING’ }` が追加されたとする。
ここで `assertNever(state)` を挟んでおかないと、開発者はそのケースのハンドリングを忘れたままデプロイし、実行時エラーを引き起こす。
`never` を使ったこのパターンを導入しておけば、「新しい状態を追加した瞬間に、対応漏れのある箇所ですべてコンパイルエラーが起きる」という、極めて堅牢な開発体験が手に入る。

—

3. 実践:非同期エラーハンドリングと `unknown` / `never` の融合

TypeScript 4.0以降、`catch (error)` の `error` はデフォルトで `unknown` 型になった。これに伴い、雑に `(error as any).message` と書くコードは即座にリファクタリング対象となるべきだ。

最後に、実務でそのまま使える、トップ型とボトム型を統合した堅牢なエラーハンドリング・ユーティリティを提示しよう。

/

  • 予期せぬ例外を安全にErrorオブジェクトへ変換し、
  • さらに特定のドメインエラーへと昇華させるユーティリティ

/

class NetworkError extends Error {
constructor(message: string) {
super(message);
this.name = ‘NetworkError’;
}
}

class ValidationError extends Error {
constructor(message: string) {
super(message);
this.name = ‘ValidationError’;
}
}

/

  • unknown型のエラーを安全にパースする

/
function normalizeError(error: unknown): Error {
if (error instanceof Error) {
return error;
}

// 文字列としてスローされた場合などのフォールバック
if (typeof error === ‘string’) {
return new Error(error);
}

return new Error(‘不明なエラーが発生しました。’);
}

/

  • 業務ロジックを実行し、型安全に結果またはエラー型を返すResultパターン

/
type Result =
| { readonly success: true; readonly data: T }
| { readonly success: false; readonly error: E };

async function safeExecute(fn: () => Promise): Promise> {
try {
const data = await fn();
return { success: true, data };
} catch (error: unknown) {
// catchされた未知の怪物(unknown)を、normalizeErrorで人間に害のないError型へ変換
const normalized = normalizeError(error);
return { success: false, error: normalized };
}
}

// — 使用例 —
async function handleUserSubmission() {
const result = await safeExecute(() => fetchUser(‘/api/user/1’));

if (!result.success) {
// result.error は確実に Error 型として扱える
console.error(`処理失敗: [${result.error.name}] ${result.error.message}`);
return;
}

// result.data は確実に User 型として扱える
console.log(`ようこそ、 ${result.data.name} さん`);
}

—

総括:型とは「未来のバグに対する保険」である

多くのジュニア・中堅エンジニアは、TypeScriptを「補完を効かせるためのツール」程度に捉えている。だが、それは宝の持ち腐れだ。

  • `unknown` を使って外部からの侵入者を検疫し、
  • 型ガード で安全性を証明し、
  • `never` で分岐の漏れをコンパイラに監視させる。

この一連のフローをインターフェース設計やアーキテクチャの根底に組み込むことで、あなたの書くコードは「動くだけのコード」から「論理的に破綻し得ないプロダクト」へと進化する。

コードレビューの現場で `any` を見かけたら、そっとこの記事の設計思想を思い出してほしい。型システムを極限まで使い倒し、プログラミングの不確実性をエンジニアの知性でねじ伏せよう。

タイトルとURLをコピーしました