TypeScript Problem DetailsをZodで検証してAPIエラーを扱う
fetchは、サーバーが400や500を返してもPromiseをrejectしません。
そのため、HTTPステータスを確認し、レスポンスの形式を検証してから、画面で扱いやすいエラーへ変換する必要があります。
今回はProblem Details形式のレスポンスをZodで検証し、共通のApiErrorとして扱う方法を紹介します。
Problem Detailsのレスポンス例
APIが以下のJSONを返すものとします。
入力エラーの例{
"type": "https://example.com/problems/validation-failed",
"title": "入力内容を確認してください。",
"status": 400,
"detail": "入力値に2件のエラーがあります。",
"instance": "/api/users",
"extensions": {
"code": "VALIDATION_FAILED",
"traceId": "00-4bf92f3577b34da6a3ce929d0e0e4736",
"errors": [
{
"field": "email",
"code": "INVALID_EMAIL",
"message": "メールアドレスの形式が正しくありません。"
}
]
}
}statusだけでなく、アプリケーション内で判定するcode、問い合わせに使用するtraceId、項目ごとのerrorsを含めています。
Zodでレスポンスを検証する
ネットワークから受け取るJSONは、TypeScript上ではunknownとして扱います。
型アサーションだけでは実際の値を確認できないため、Zodのスキーマを定義します。
problemDetails.tsimport { z } from 'zod';
const validationErrorSchema = z.object({
field: z.string(),
code: z.string(),
message: z.string(),
});
export const problemDetailsSchema = z.object({
type: z.string().optional(),
title: z.string().optional(),
status: z.number().int().optional(),
detail: z.string().optional(),
instance: z.string().optional(),
extensions: z.object({
code: z.string(),
traceId: z.string().optional(),
errors: z.array(validationErrorSchema).optional(),
}).loose(),
});
export type ProblemDetails = z.infer<typeof problemDetailsSchema>;
export type ValidationError = z.infer<typeof validationErrorSchema>;.loose()を指定すると、extensionsに未定義の項目が追加されても検証を通過します。
API側で拡張項目を増やす可能性がある場合に、既存のフロントエンドがすぐ動かなくなることを避けられます。
画面で扱うApiErrorを作成する
検証済みの値をApiErrorへ格納します。
ApiError.tsimport type { ProblemDetails, ValidationError } from './problemDetails';
export class ApiError extends Error {
constructor(private readonly problem: ProblemDetails) {
super(problem.title ?? 'API Error');
this.name = 'ApiError';
}
get status(): number | undefined {
return this.problem.status;
}
get code(): string {
return this.problem.extensions.code;
}
get traceId(): string | undefined {
return this.problem.extensions.traceId;
}
get validationErrors(): ValidationError[] {
return this.problem.extensions.errors ?? [];
}
get isValidationError(): boolean {
return this.status === 400 && this.code === 'VALIDATION_FAILED';
}
get isUnauthorized(): boolean {
return this.status === 401;
}
}画面側はJSONの構造を毎回確認せず、isValidationErrorなどのプロパティで分岐できます。
fetchのレスポンスを変換する
成功時と失敗時をまとめて処理する関数を作成します。
apiClient.tsimport { ApiError } from './ApiError';
import { problemDetailsSchema } from './problemDetails';
const readJson = async (response: Response): Promise<unknown> => {
const contentType = response.headers.get('content-type') ?? '';
if (!contentType.includes('json')) return undefined;
return response.json();
};
export const request = async <T>(
url: string,
init?: RequestInit,
): Promise<T> => {
let response: Response;
try {
response = await fetch(url, init);
} catch (error) {
const timedOut = error instanceof Error && error.name === 'AbortError';
throw new ApiError({
title: timedOut ? 'Request Timeout' : 'Network Error',
status: timedOut ? 504 : 503,
instance: url,
extensions: {
code: timedOut ? 'TIMEOUT_ERROR' : 'NETWORK_ERROR',
},
});
}
const data = await readJson(response);
if (response.ok) return data as T;
const parsed = problemDetailsSchema.safeParse(data);
if (parsed.success) throw new ApiError(parsed.data);
throw new ApiError({
title: 'Unexpected API Error',
status: response.status,
instance: url,
extensions: { code: 'UNEXPECTED_RESPONSE' },
});
};エラーレスポンスのContent-TypeがJSONでない場合や、JSONの形が想定と異なる場合も、最後のApiErrorへ変換します。
上記では成功時の値をTへ型アサーションしています。外部APIなど、成功レスポンスも信用できない場合は、成功用のZodスキーマを引数で受け取り、同じように検証します。
画面でエラーを表示する
フォームの送信処理では、エラーの種類に応じて表示を変えます。
フォーム送信処理try {
await request('/api/users', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(formData),
});
} catch (error) {
if (!(error instanceof ApiError)) throw error;
if (error.isValidationError) {
for (const item of error.validationErrors) {
setFieldError(item.field, item.message);
}
return;
}
if (error.isUnauthorized) {
navigate('/login');
return;
}
showError('処理に失敗しました。', error.traceId);
}利用者向けの文言に、サーバーのdetailをそのまま表示する方法は避けます。内部情報が含まれる可能性があるためです。
画面には用途に合わせた文言を表示し、traceIdは問い合わせ番号として表示またはログへ記録します。
確認するポイント
| 状況 | 期待する結果 |
|---|---|
200とJSON | 成功データを返す |
400と正しいProblem Details | ApiErrorへ変換する |
500とHTML | UNEXPECTED_RESPONSEにする |
| 通信に失敗 | NETWORK_ERRORにする |
AbortControllerで中断 | TIMEOUT_ERRORにする |
Problem Detailsへ形式をそろえると、HTTPクライアント、フォーム、共通エラー画面の役割を分けやすくなります。