zukucode
主にWEB関連の情報を技術メモとして発信しています。

TypeScript Problem DetailsをZodで検証してAPIエラーを扱う

fetchは、サーバーが400や500を返してもPromiseをrejectしません。

そのため、HTTPステータスを確認し、レスポンスの形式を検証してから、画面で扱いやすいエラーへ変換する必要があります。

今回はProblem Details形式のレスポンスをZodで検証し、共通のApiErrorとして扱う方法を紹介します。

生成されたTypeScript型や型アサーションだけでは、ネットワークから届いた値そのものは検証されません。通常はAPI契約を信頼して型だけで扱う選択もできますが、エラー処理はプロキシのHTMLや想定外の本文も受け取り得ます。そのため、画面の分岐に利用する最小限の項目を実行時に検証し、形式が違う応答は別のエラーとして扱います。

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.ts
import { 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.ts
import 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.ts
import { 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 DetailsApiErrorへ変換する
500とHTMLUNEXPECTED_RESPONSEにする
通信に失敗NETWORK_ERRORにする
AbortControllerで中断TIMEOUT_ERRORにする

Problem Detailsへ形式をそろえると、HTTPクライアント、フォーム、共通エラー画面の役割を分けやすくなります。


関連記事