0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

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

0
Posted at

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

参考資料(公式)

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?