Article

🛡️ TypeScriptのunknownと型ガード:外部APIのJSONを安全に扱う

typescript, javascript, api, backend

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受信直後に検証してからアプリ内部へ渡しましょう。型が付いていることと、実際のデータが正しいことは別です。

参考資料(公式)