TypeScriptのユニオン型と型ガード:型の絞り込みからneverによる網羅チェックまで
string | numberの分岐から、状態を安全に表す判別可能なユニオン型まで解説。typeof、unknown、neverの役割を、配送状態を扱うコード例と練習問題で学びます。
関数の引数に「文字列または数値」を渡したいとき、すべてを any にすると、存在しないメソッドの呼び出しを見逃してしまいます。TypeScriptでは、取り得る型をユニオン型で列挙し、条件分岐で扱える型を狭める設計ができます。この記事では、関数と基本的な型の書き方が分かる方向けに、入力値の整形と配送状態の表示を実装します。
【この記事でできるようになること】
・string | number を安全に処理する。
・「処理中・成功・失敗」と、それぞれに必要なデータを一緒に表す。
・状態を追加したときに、処理の書き忘れをコンパイラで見つける。
【1. ユニオン型は、取り得る型の候補を表す】
string | number は、値が文字列か数値のどちらかであることを表します。文字列にだけある trim() を無条件で呼ぶことはできません。まず typeof で数値を判定し、数値ならその場で結果を返します。残る経路では文字列として扱えます。
ファイル: reference.ts
function formatReference(value: string | number): string {
if (typeof value === "number") {
return `No.${value.toFixed(0)}`;
}
return value.trim().toUpperCase();
}
console.log(formatReference(42)); // No.42
console.log(formatReference(" ab-7 ")); // AB-7
このように、条件や処理の流れを使って型の候補を減らすことを「型の絞り込み(narrowing)」と呼びます。typeof など、判定を通じて型を絞り込む仕組みが型ガードです。詳しい判定規則は 公式のNarrowing(https://www.typescriptlang.org/docs/handbook/2/narrowing.html) で確認できます。
【2. 成功したときだけ宛先を持つ状態を表す】
配送状態を単なる文字列と、任意の宛先・エラー文の組み合わせで表すと、「成功なのに宛先がない」データまで作れてしまいます。そこで状態ごとにオブジェクトの形を分けます。共通の status に異なる文字列リテラルを割り当てると、その値からオブジェクト全体の型を判別できます。これが判別可能なユニオン型です。
ファイル: delivery.ts
type DeliveryState =
| { status: "pending" }
| { status: "sent"; recipient: string }
| { status: "failed"; reason: string };
function assertNever(value: never): never {
throw new Error(`想定外の状態: ${JSON.stringify(value)}`);
}
function describeDelivery(state: DeliveryState): string {
switch (state.status) {
case "pending":
return "配送待ち";
case "sent":
return `配送完了: ${state.recipient}`;
case "failed":
return `配送失敗: ${state.reason}`;
default:
return assertNever(state);
}
}
console.log(describeDelivery({ status: "pending" }));
console.log(describeDelivery({ status: "sent", recipient: "reader@example.com" }));
console.log(describeDelivery({ status: "failed", reason: "宛先不明" }));
出力は順に「配送待ち」「配送完了: reader@example.com」「配送失敗: 宛先不明」です。sent の分岐では recipient を、failed の分岐では reason を使えます。別の分岐で違うプロパティに触れると型エラーになり、状態とデータの取り違えを防げます。
【3. neverで、分岐の追加漏れに気付く】
すべての状態を処理した後、default に残る候補はありません。その値の型が never です。例えば型に { status: "retrying"; attempt: number } を追加し、switchを変更しないで型チェックすると、assertNever(state) に渡せる型ではなくなりエラーになります。追加した状態の処理を書けば解消します。
この設計は、画面の読み込み状態、注文の状態、ファイル変換の結果などにも使えます。例外を投げる部分は想定外の実行時データへの最後の備えですが、型チェックそのものが外部データを検証してくれるわけではありません。
【4. よくある落とし穴】
・if (value) は空文字や0も除外します。「値がない場合」だけを除きたいなら、nullやundefinedを明示的に判定します。
・typeof null は "object" です。オブジェクトとして扱う前にnullも除外してください。
・as DeliveryState と書いても、JSONの中身は検証されません。外部入力は unknown として受け、必要なフィールドを実行時に確認する設計にします。
【実行方法と練習問題】
TypeScriptがインストールされた作業用フォルダで、上の例をそれぞれ reference.ts、delivery.ts に保存します。次の設定で npx tsc -p . を実行し、node dist/reference.js と node dist/delivery.js で出力を確認できます。既存プロジェクトの設定は上書きせず、独立したフォルダで試してください。
ファイル: tsconfig.json
{"compilerOptions":{"target":"ES2022","module":"NodeNext","strict":true,"outDir":"dist","types":[],"lib":["ES2022","DOM"]},"include":["*.ts"]}
練習として retrying 状態を追加し、最初に型エラーが出ることを確認してください。その後、再試行回数を表示する分岐を追加します。default を単に「不明」と表示する処理に変えた場合との違いも考えてみましょう。
【よくある質問】
【型ガードを書けばanyは不要ですか?】
何が入るか分からない値は unknown として受け、確認してから使う方法をまず検討できます。ただし検証内容は必要な構造に応じて増えるため、複雑な外部データでは検証用ライブラリも選択肢になります。
【型を追加したら、実行中のアプリも自動で変わりますか?】
変わりません。型は開発時の検査に使われます。ソースを修正し、型チェックとビルドを行い、生成したアプリを実行・配布する必要があります。
【参考資料】
・TypeScript Handbook: Narrowing(https://www.typescriptlang.org/docs/handbook/2/narrowing.html)
・TypeScript Handbook: Everyday Types(https://www.typescriptlang.org/docs/handbook/2/everyday-types.html)