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

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

fetchは、サーバーが400500を返しても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.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クライアント、フォーム、共通エラー画面の役割を分けやすくなります。


関連記事