Article
🛡️ TypeScriptのunknownと型ガード:外部APIのJSONを安全に扱う
fetch() で外部APIからJSONを受け取ると、つい次のように書きたくなります。
// これは「型が正しい」という実行時の保証にはならない
const user = (await response.json()) as ApiUser;
console.log(user.displayName.toUpperCase());
しかし、相手のAPIが突然 displayName: null を返したら実行時に失敗します。TypeScriptの型は実行時の入力データを検証してくれるわけではありません。 as は「この型として扱う」という開発者の指示であり、JSONをチェックする処理を生成しません。
この記事では、unknown と型ガード(実行時の条件チェックによって、値の型を絞り込む処理)を使い、外部APIとの境界で必要な検証を行います。標準のTypeScriptだけで動く例を示し、検証ライブラリを使うべき場面も整理します。
any と unknown の決定的な違い
| 型 | 何が入る? | 型を確かめずにプロパティを読む? | よく使う場面 |
|---|---|---|---|
any |
何でも入る | コンパイラーが許してしまう | 型情報のない既存コードとの段階的な接続 |
unknown |
何でも入る | 原則できない | APIレスポンス、JSON、外部からの入力 |
function unsafe(value: any) {
return value.name.toUpperCase(); // 型検査をすり抜ける
}
function safe(value: unknown) {
// return value.name.toUpperCase(); // コンパイルエラー
if (typeof value === "string") {
return value.toUpperCase(); // string と確定した範囲だけで利用する
}
return "文字列ではありません";
}
unknown は厳格すぎる型ではなく、「まだ信用していない値」という意思表示です。TypeScript公式Handbookでも、any より安全な選択肢として説明されています。
例:ユーザー情報を受け取る
外部APIが次の形式を返す契約だとします。
{
"id": "u_123",
"displayName": "Alice",
"age": 28,
"roles": ["viewer", "editor"]
}
想定する型は以下です。age は未登録なら null を許可しますが、未定義は許しません。
export type ApiUser = {
id: string;
displayName: string;
age: number | null;
roles: Array<"viewer" | "editor">;
};
型定義だけでは、実際のサーバー応答がこの契約どおりかは分かりません。
コピペできる実装:値を1つずつ検証する
次のコードを user-guard.ts に保存できます。isApiUser が true を返した場合にだけ、TypeScriptは引数を ApiUser として扱えます。
export type ApiUser = {
id: string;
displayName: string;
age: number | null;
roles: Array<"viewer" | "editor">;
};
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function isRole(value: unknown): value is ApiUser["roles"][number] {
return value === "viewer" || value === "editor";
}
export function isApiUser(value: unknown): value is ApiUser {
if (!isRecord(value)) return false;
return (
typeof value.id === "string" && value.id.length > 0 &&
typeof value.displayName === "string" &&
(value.age === null ||
(typeof value.age === "number" &&
Number.isInteger(value.age) && value.age >= 0)) &&
Array.isArray(value.roles) && value.roles.every(isRole)
);
}
export async function fetchApiUser(url: string): Promise<ApiUser> {
const response = await fetch(url);
if (!response.ok) {
throw new Error(`HTTP request failed: ${response.status}`);
}
// JSONの解析結果をそのまま信頼しない
const body: unknown = await response.json();
if (!isApiUser(body)) {
throw new Error("API response has an unexpected shape");
}
return body;
}
要点は value is ApiUser という返り値の書き方です。これは**型述語(type predicate)**と呼ばれます。関数の判定結果に応じて、呼び出し元でも型が絞り込まれます。
isRecord はオブジェクト以外を除外します。JavaScriptの typeof null は "object" なので、value !== null が必須です。配列も typeof だけでは通常のオブジェクトと区別できないため、Array.isArray で除きます。
roles.every(isRole) はすべての要素をチェックします。roles が [] の場合、every() は true です。少なくとも1つの役割が必須なら、value.roles.length > 0 も追加してください。
Number.isInteger を使うのは、この例で年齢を整数とする契約だからです。小数の値を扱うAPIなら別の検証に変えます。 NaN や Infinity も通さない設計です。
動作確認:正常系だけでは不十分
上の user-guard.ts と同じフォルダーに、次の user-guard.test.ts を作ります。
import { isApiUser } from "./user-guard";
const valid: unknown = {
id: "u_123", displayName: "Alice", age: 28, roles: ["viewer"]
};
const cases: Array<[string, unknown, boolean]> = [
["valid", valid, true],
["null", null, false],
["array", [], false],
["missing age", { id: "u_1", displayName: "A", roles: [] }, false],
["null name", { id: "u_1", displayName: null, age: 20, roles: [] }, false],
["unknown role", { id: "u_1", displayName: "A", age: 20, roles: ["admin"] }, false],
["negative age", { id: "u_1", displayName: "A", age: -1, roles: [] }, false],
];
for (const [name, input, expected] of cases) {
if (isApiUser(input) !== expected) {
throw new Error(`Unexpected validation result: ${name}`);
}
}
console.log(`${cases.length} cases passed`);
TypeScriptのコンパイラー tsc がインストールされていれば、次のように確認できます。
tsc --strict --target ES2022 --lib ES2022,DOM --module commonjs user-guard.ts user-guard.test.ts
node user-guard.test.js
1行目は --strict で厳しい型検査を有効にし、--target で出力するJavaScriptの言語レベルを、--lib で使用できる標準APIの型定義を、--module commonjs でNode.jsに読み込ませるモジュール形式を指定しています。2行目は生成されたJavaScriptのテストを実行します。環境によってはプロジェクトの package.json に "type": "module" があるため、その場合はモジュール設定に合わせて変更してください。
期待する出力は 7 cases passed です。age が未定義なら検証失敗になる点も確認できます。
ハマりどころと設計判断
1. as ApiUser は検証ではない
const raw: unknown = JSON.parse('{"id":123}');
const user = raw as ApiUser; // 実行時のidはnumberのまま
as によってデータ自体は変わりません。unknown から型へ変換する場所を絞り、実際の条件チェックを通すのが重要です。型ガード関数の実装が間違っていれば、TypeScriptもそれを見抜けないためテストが必要です。
2. 必須・任意・null を混同しない
age?: number はプロパティ自体が存在しないケースを許す型です。一方、今回の age: number | null はキーが存在する前提で、値として null を許します。相手のAPI仕様に合わせて検証してください。
3. 余分なプロパティをどうするか
この型ガードは必要なキーを確認するだけで、未定義の追加キーは拒否しません。例えばAPIが debug: true を追加しても通常は利用できます。追加キーを拒否する厳格なスキーマが必要なら、その判定を別途実装します。
4. HTTPエラー・JSON解析エラー・形式エラーは別物
response.okがfalse:HTTPエラーの応答を受け取っている。response.json()が失敗:JSONとして解析できない、または読み取りに問題がある。isApiUserがfalse:JSONは解析できたが、期待するデータ形式ではない。
ログやアラートはこの区別を保持すると調査しやすくなります。入力JSON全文をログへ記録すると個人情報などが混入し得るため、原則として避けます。
自作の型ガードと検証ライブラリの使い分け
| 状況 | 選択肢 |
|---|---|
| 2〜3個の簡単な項目をチェック | 自作の型ガードで十分な場合が多い |
| 多数のAPI・ネストしたオブジェクト・配列・詳細なエラー表示 | Zodなどのスキーマ検証ライブラリを検討 |
| 入力値を変換・正規化したい | 検証と変換の責任を分け、明示的に扱う |
| 不正データを部分的に受け入れたい | 「何を捨てたか」「何を採用したか」を運用ルールとして決める |
検証ライブラリを使う場合も、**「外部入力を信頼してよいのは検証を通過した後」**という原則は同じです。まず境界を決め、検証ルールをテスト可能な形で集約することが先です。
まとめ
unknown は「型が不明な値を安全に受け取るための型」、型ガードは「実行時の値を確認して型を絞り込む仕組み」です。as だけに頼らず、JSON受信直後に検証してからアプリ内部へ渡しましょう。型が付いていることと、実際のデータが正しいことは別です。