APIから返るユーザー情報に as User を付けた。エディターの赤線は消えたのに、画面では name.trim is not a function が出る。これは、TypeScriptへ伝えた型と、実際に届いた値が食い違っている状態です。
fetchの外から来るJSONは unknown で受け、プロパティを調べてから使います。 小さいデータなら手書きの型ガード、項目が増えるならZodのスキーマで検証すると、どの値を受け入れるのかを管理しやすくなります。
as Userは「検査済み」にするコードではない
as User は、コンパイラーにその値をUserとして扱わせる型アサーションです。実行時に名前を文字列へ変換したり、足りない項目を調べたりはしません。型アサーション自体はコンパイル後に消えます。TypeScript公式の型アサーション解説でも、この点が明示されています。
async function getUser(): Promise<User> と戻り値を書くだけでも、APIのJSONは検査されません。受信側で any の値を返していれば、その宣言をすり抜けます。fetchJson<User>() のようなジェネリック関数も、内部が単なる as T なら事情は同じです。
ここでは「idは正の安全な整数、nameは前後の空白を除いて1文字以上」という受信ルールを使います。まず、同じデータを型アサーションと型ガードで扱ってみます。
unknownを受け取り、使う項目を型ガードで確かめる
src/manual.ts
export type User = { id: number; name: string };
// 比較用。受信したJSONにこの関数を使わない。
export function unsafeName(raw: unknown): string {
const user = raw as User;
return user.name.trim();
}
export function isUser(value: unknown): value is User {
return typeof value === 'object' && value !== null &&
!Array.isArray(value) &&
'id' in value && typeof value.id === 'number' &&
Number.isSafeInteger(value.id) && value.id > 0 &&
'name' in value && typeof value.name === 'string' &&
value.name.trim().length > 0;
}
export function manualName(raw: unknown): string | null {
if (!isUser(raw)) return null;
return raw.name.trim();
}
unsafeName({ id: 1, name: 42 }) はコンパイルできても、実行時に落ちます。一方、manualName は検証に通らない値なら null を返し、文字列への操作に進みません。
unknown は値を検証する機能そのものではありません。「中身を確かめるまで、そのプロパティを自由には使わない」という入口です。そこから typeof、null判定、プロパティの存在確認を順に行っています。
value is User は、関数がtrueを返した場合にUserとして扱えることを伝える型述語です。書いた判定が正しいかは実装者の責任なので、型だけ書いて常にtrueを返すようなガードでは守れません。上の例も、項目を増やしたら判定とテストを一緒に変えます。仕組みはTypeScript公式の型述語で確認できます。
検証とデータの整形を分けて考える
この isUser は入力を変更しません。{ id: 1, name: " Aki ", extra: "メモ" } は検証に通りますが、nameの空白もextraも残ります。manualName が表示名を返すときにだけ、空白を取り除いています。
型ガードを通せば余分な項目が消える、という意味ではありません。検証後のオブジェクトを丸ごと別のAPIへ送るのではなく、送信する項目はその用途に合わせて選びます。
Zodで検証し、成功したdataだけを返す
Zodを使う場合は、User型を別に手書きする代わりに z.infer でスキーマから取り出せます。safeParse の成功・失敗を分岐し、成功時には入力のrawではなく parsed.data を使うのがポイントです。Zod公式の基本操作もこの形で説明しています。
src/client.ts
import { z } from 'zod';
export const UserSchema = z.object({
id: z.number().int().positive(),
name: z.string().trim().min(1),
});
export type User = z.infer<typeof UserSchema>;
export type UserResult =
| { ok: true; data: User }
| { ok: false; kind: 'network' | 'body' }
| { ok: false; kind: 'http'; status: number }
| { ok: false; kind: 'schema'; fields: string[] };
export async function loadUser(
url: string,
request: typeof fetch = fetch,
): Promise<UserResult> {
let response: Response;
try {
response = await request(url);
} catch {
return { ok: false, kind: 'network' };
}
if (!response.ok) {
return { ok: false, kind: 'http', status: response.status };
}
let raw: unknown;
try {
raw = await response.json();
} catch {
return { ok: false, kind: 'body' };
}
const parsed = UserSchema.safeParse(raw);
if (!parsed.success) {
// 入力値、レスポンス全文、URLやトークンをログへ渡さない。
const fields = [...new Set(parsed.error.issues.map(issue => {
const key = issue.path[0];
return key === 'id' || key === 'name' ? key : 'root';
}))];
return { ok: false, kind: 'schema', fields };
}
return { ok: true, data: parsed.data };
}
export function userLabel(result: UserResult): string {
if (result.ok) return result.data.name;
if (result.kind === 'http' && result.status === 404) {
return 'ユーザーが見つかりません';
}
return 'ユーザー情報を取得できませんでした';
}
このUserSchemaはnameをtrimしてから空文字かを調べます。また、通常の z.object は、定義していないキーを検証後の出力から取り除きます。先ほどの入力なら、返るdataは { id: 1, name: "Aki" } です。未知のキー自体をエラーにしたい場合の z.strictObject とは動きが違います。Zod公式のオブジェクト仕様を参照してください。
スキーマが正しい形だと判断しても、そのユーザー情報を閲覧してよいかは別の問題です。認証・認可はサーバー側で判断します。また、nameが文字列であることはHTMLとして安全であることを意味しません。画面へ出すときはテキストとして扱い、innerHTML にそのまま渡さないでください。
HTTPエラーと検証エラーを空データにまとめない
loadUser は次の4種類に分けて失敗を返します。404でもPromiseが通常どおり解決するfetchの挙動は、fetchでJSONを取得する基本とエラー処理で追えます。
| kind | 分かること | 確認する場所 |
|---|---|---|
| network | fetchの呼び出しが失敗した | 接続先、接続状況、ブラウザの通信エラー |
| http | 成功扱いでないHTTPステータスが返った | statusとAPI側の仕様 |
| body | 本文をJSONとして読み取れなかった | 空の本文、不正なJSON、読取中の中断など |
| schema | JSONにはなったがUserの条件に合わない | fieldsで示すid・name・rootとAPIの変更 |
この例のAPIは成功時にUserのJSONを返す契約なので、本文がない204もbodyになります。204を正常な「データなし」として使うAPIなら、その仕様に合わせた成功分岐を追加します。
userLabel は404とその他の失敗を別の文にします。失敗を空のUserや空配列に置き換えると、「取得できなかった」と「取得した結果が0件だった」の区別がつかなくなるためです。ログへ残す場合も、上の結果のkind、status、許可したfields程度から始め、レスポンス全文や認証情報を出さないようにします。
小さな検証環境で、不正なJSONを通してみる
次の2ファイルと、上の src/manual.ts・src/client.ts を同じプロジェクトに保存します。この例はNode.js 24.13.0、TypeScript 7.0.2、Zod 4.6.2で確認しています。
package.json
{
"name": "fetch-response-validation-example",
"private": true,
"type": "module",
"scripts": {
"build": "tsc",
"test": "npm run build && node --test test.mjs"
},
"dependencies": { "zod": "4.6.2" },
"devDependencies": { "typescript": "7.0.2" }
}
tsconfig.json
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"lib": ["ES2022", "DOM"],
"strict": true,
"noEmitOnError": true,
"rootDir": "src",
"outDir": "dist"
},
"include": ["src/**/*.ts"]
}
npm install のあと npm run build でコンパイルできます。ブラウザ用のコードとして組み込むときは、既存のViteなどのビルド環境にsrcの実装を移し、tsconfigを丸ごと上書きしないでください。
手元で比較するなら、コンパイル後にNodeの --input-type=module を使って次を実行できます。外部APIには接続せず、作ったResponseを返すので、壊れた値も再現できます。
node --input-type=module <<'JS'
import { loadUser, userLabel } from './dist/client.js';
const result = await loadUser('/api/users/1', async () =>
Response.json({ id: 1, name: 42 }));
console.log(result);
console.log(userLabel(result));
JS
{ ok: false, kind: 'schema', fields: ['name'] } と、取得できなかったことを伝える日本語が出れば、型の異常を画面表示の前で止められています。nameを ' Aki ' に変えると、成功したdataと Aki が返ります。
例の検証では、null、配列、必須項目の欠落、文字列のid、0・小数・安全な整数を超えるid、数値・空文字・空白だけのnameを拒否できることを確認しました。正常値の整形、余分なキーの除去、通信失敗、404、不正なJSON、204、ログ用結果に受信値を残さないことも確認しています。実APIへつなぐ際は、実際の成功・失敗レスポンスでも同じ境界を確かめてください。
手書きで足りるか、Zodへ移すか
| 今の状態 | 選び方 |
|---|---|
| 1か所で使う、平坦で小さいデータ | 手書きガードでも管理しやすい。型と判定の変更漏れをテストで確認する |
| 配列や入れ子、任意項目が増えている | Zodで条件をまとめると、検証処理を追いやすい |
| 型と検証ルールを二重に直している | スキーマを基準にz.inferで型を取り出す |
| とりあえずasを外したい | 1つのAPI境界だけunknownへ変え、成功時の返り値を決めるところから始める |
まず1か所の response.json() をunknownで受け、画面が使う項目を検証してください。次に、正常な値と壊れた値を1つずつ試します。検証済みの値だけを返す入口ができれば、画面のあちこちで as User を重ねずに済みます。