🗿

【Next.js】散らかりにくいREST APIのエラーハンドリング

に公開

Next.jsでAPIリクエストがエラーとなった場合のトースト表示を実装していたのですが、production buildではメッセージが隠されてしまう現象に遭遇しました。

An error occurred in the Server Components render. The specific message is omitted in production builds to avoid leaking sensitive details. A digest property is included on this error instance which may provide additional details about the nature of the error.

どうやらNetwork Boundaryを跨いで受け取ったError.messageをそのまま表示しようとすると、セキュリティの観点からReactがメッセージを隠蔽してくれるようです。その対策として、Server Actionからはシリアライズ可能なエラーを値として返す必要があるのですが、無秩序にエラーを扱っているとすぐにコードのメンテナンス性が低下してしまいます。そこで、散らかりにくい (と思う) エラーハンドリングの書き方をまとめました。

前提

以下の技術スタックを使用します。

  • フロントエンドはNext.js App Router
  • バックエンドはOpenAPIでスキーマ定義したREST API
  • openapi-fetchでAPIクライアントを実装

https://zenn.dev/frontendflat/articles/19f4a8423ac5cb

問題の確認から

冒頭で触れたReactの挙動ですが、次のようなコードを書くと発生します。ポイントとしては、Server Actionが例外をスローしており、呼び出し元のクライアントコンポーネントで try/catch して捕捉したErrorオブジェクトに直接アクセスしています。

// Server Action
const postIngredient = () => {
  throw new Error("食材の登録に失敗しました");
};

export const createIngredientAction = async (): Promise<void> => {
  postIngredient();
};
// クライアントコンポーネント
"use client";

export const CreateIngredientButton = () => {
  const [isPending, startTransition] = useTransition();

  const handleClick = () => {
    startTransition(async () => {
      try {
        await createIngredientAction();
        toast.success("食材を登録しました");
      } catch (error) {
        if (error instanceof Error) {
          toast.error(error.message);
          // ^^^^^ 🚫 Error.messageに直接アクセスしている 
        } else {
          toast.error("不明なエラーが発生しました");
        }
      }
    });
  };

  return (
    <button type="button" onClick={handleClick} disabled={isPending}>
      食材を登録する
    </button>
  );
};

誤ってセキュアな情報がブラウザから見えてしまうインシデントを防いでくれるのですが、これではエラーの原因に応じてユーザーフレンドリーなメッセージを表示するという機能が実装できません。そこでエラーを例外ではなく、Result型のような値として扱うことで対応します。

// Server Action
export const createIngredientAction = async (): Promise<
  Result<undefined, { code: string; message: string }>
> => {
  try {
    postIngredient();

    return {
      type: "success",
      data: undefined,
    };
  } catch (error) {
    if (error instanceof Error) {
      return {
        type: "failure",
        error: {
          code: "UNKNOWN_ERROR",
          message: error.message,
        },
      };
    }

    return {
      type: "failure",
      error: {
        code: "UNKNOWN_ERROR",
        message: "不明なエラーが発生しました",
      },
    };
  }
};
// クライアントコンポーネント
"use client";

export const CreateIngredientButton = () => {
  const [isPending, startTransition] = useTransition();

  const handleClick = () => {
    startTransition(async () => {
      const result = await createIngredientAction();

      if (result.type === "success") {
        toast.success("食材を登録しました");
      } else {
        toast.error(result.error.message);
        ^^^^^ ✅ カスタムエラーオブジェクトを通じてメッセージにアクセスしている
      }
    });
  };
  return (
    <button type="button" onClick={handleClick} disabled={isPending}>
      食材を登録する
    </button>
  );
};

これで、クライアントコンポーネントはServer Actionから受け取ったエラーメッセージを表示することができます!ただ、TypeScriptにおけるResult型は言語標準の仕組みではないため、導入する範囲を限定するなど、何らかの秩序を持って扱う必要があります。

Result型の説明

書き方はいくつかありますが、以下のように成功・失敗のEither型を定義します。type プロパティはDescriminated Union Typeとして成功・失敗の判別に使います。

export type SuccessResult<T> = {
  type: "success";
  data: T;
};

export type ErrorResult<E> = {
  type: "error";
  error: E;
};

export type Result<T = unknown, E> = SuccessResult<T> | ErrorResult<E>;

https://speakerdeck.com/daitasu/typescript-de-railway-oriented-programming-xing-an-quan-naerahandoringuwozuo-ru?slide=7

APIリクエストの結果をResult型でラップする

APIリクエストの結果をResult型にしたいのですが、愚直に書くとボイラプレートコードが増えてしまいます。本当はopenapi-fetchで作成したクライアントを拡張して対応したかったのですが、型推論を維持したまま実装するのが難しかったので諦めました。

// これを拡張したかった 😭
export const fetchClient = createClient<paths>({
  baseUrl: process.env.NEXT_PUBLIC_API_URL,
});

代わりに withResult のようなユーティリティ関数を作ってお茶を濁します (ボイラープレートコードが短くなっただけなのが悲しい...)。

/lib/openapi-fetch/utils.ts
import type { FetchResponse } from "openapi-fetch";

import type { ApiError, MediaType } from "@/lib/openapi-fetch/types";
import type { Result } from "@/types";

export const withResult = <T extends Record<string | number, unknown>, Options, Media extends MediaType = MediaType>(
  callbackFn: () => Promise<FetchResponse<T, Options, Media>>,
): (() => Promise<Result<NonNullable<FetchResponse<T, Options, Media>["data"]>, ApiError>>) => {
  return async () => {
    const { error, data } = await callbackFn();

    if (error) {
      return {
        type: "failure",
        error,
      };
    }

    return {
      type: "success",
      data: data as NonNullable<FetchResponse<T, Options, Media>["data"]>,
    };
  };
};

呼び出し側はこんな感じで書けます。

/features/ingredients/api/index.ts
import { fetchClient } from "@/lib/openapi-fetch/client";
import { withResult } from "@/lib/openapi-fetch/utils";

import type { ApiError, RequestBody, ResponseData } from "@/lib/openapi-fetch/types";
import type { Result } from "@/types";

export const createIngredient = async (
  params: RequestBody<"post", "/ingredients">,
): Promise<Result<ResponseData<"post", "/ingredients">, ApiError>> => {
  return withResult(() =>
    fetchClient.POST("/ingredients", {
      body: params,
    }),
  )();
};

想定外のエラーもResult型に畳み込む

Fetch APIがそうであるように、openapi-fetchで実装したAPIクライアントもネットワークエラーといった想定外のエラーが発生すると例外をスローします。ドキュメントによると onError ハンドラーで例外を捕捉できるため、すべてResult型に畳み込みます。

/lib/openapi-fetch/client.ts
import type { Middleware } from "openapi-fetch";

// onErrorを実装したエラーハンドリング用のミドルウェア
const errorHandlingMIddleware: Middleware = {
  onError({ error }) {
    // サーバーへのエラーログを集約
    console.error(error);

     if (error instanceof TypeError) {
      // レスポンスをResult型に畳み込む
      return Response.json(
        {
          type: "failure",
          error: {
            code: "NETWORK_ERROR",
            message: error.message,
          },
        },
        {
          status: 500,
        },
      );
    }

    return Response.json(
      {
        type: "failure",
        error: {
          code: "UNKNOWN_ERROR",
          message: error instanceof Error ? error.message : "An unknown error occurred",
        },
      },
      {
        status: 500,
      },
    );
  },
};
// APIクライアント
export const fetchClient = createClient<paths>({
  baseUrl: process.env.NEXT_PUBLIC_API_URL,
});

// APIクライアントにミドルウェアを適用
fetchClient.use(errorHandlingMIddleware);

型安全にエラーを扱うためのOpenAPIスキーマ

ユーザーに適切なエラーメッセージを表示するには、APIリクエストが失敗した原因を何らかの方法で知る必要があります。ここでは、簡易なエラーコードをフロントエンドとバックエンドインターフェースとして、OpenAPIスキーマからバックエンドが返すエラーの種別を読み取れるように定義します。何が嬉しいのか順を追って説明します。

APIエラーをスキーマで表現

OpenAPIのComponentsを使って、APIエラーの種別ごとにスキーマを定義します。OpenAPI 3.1からは導入された allOfconst を使うことで、InternalServerErrorApiError スキーマを継承したスキーマとして簡単に定義することができます。

error.yaml
ApiError:
  type: object
  properties:
    code:
      type: string
    message:
      type: string
  required:
    - code
    - message
InternalServerError:
  # ApiErrorを満たすスキーマであることが条件となる
  allOf:
    - $ref: "#/ApiError"
    - type: object
      properties:
        code:
          type: string
          # TypeScriptでいう文字列リテラルのようなもの
          const: "INTERNAL_SERVER_ERROR"

上記は汎用的なAPIエラーですが、エラーの表示にはより具体的なエラーコード (DUPLICATE_NAME_FOUND など) が必要になります。

例えば、食材を登録するAPIエンドポイント (POST /ingredients) は食材名の重複や、長すぎる食材名を含むリクエストに対してバリデーションエラーを返すとします。こういったバリデーションエラーにそれぞれユニークなエラーコードを与えることで、フロントエンドから具体的なエラーを識別できるようになります。

ingredient.yaml
post:
  responses:
    "201":
      description: Created
    "409":
      description: Conflict
      content:
        application/json:
          schema:
            allOf:
              - $ref: "../../components/schemas/error.yaml#/ApiError"
              - type: object
                properties:
                  code:
                    type: string
                    const: "DUPLICATE_NAME_FOUND"
    "422":
      description: Unprocessable Content
      content:
        application/json:
          schema:
            allOf:
              - $ref: "../../components/schemas/error.yaml#/ApiError"
              - type: object
                properties:
                  code:
                    type: string
                    const: "INAPPROPRIATE_INGREDIENT_NAME"
    "500":
      description: Internal Server Error
      content:
        application/json:
          schema:
            $ref: "../../components/schemas/error.yaml#/InternalServerError"

フロントエンドでAPIエラーをハンドリング

OpenAPI TypeScriptでスキーマから型ファイルを生成したうえで、その情報を食わせたAPIクライアントをつくります。

/lib/openapi-fetch/client.ts
// pathsにAPIの型情報が入っている
export const fetchClient = createClient<paths>({
  baseUrl: process.env.NEXT_PUBLIC_API_URL,
});
生成されるTypeScriptの型ファイル
openapi.gen.ts
export interface paths {
    "/ingredients": {
        post: {
            parameters: {
                query?: never;
                header?: never;
                path?: never;
                cookie?: never;
            };
            requestBody: {
                content: {
                    "application/json": {
                        /** @example Tomato */
                        name: string;
                        /**
                         * Format: uri
                         * @example https://example.com/images/tomato.png
                         */
                        imageUrl?: string;
                    };
                };
            };
            responses: {
                /** @description Created */
                201: {
                    headers: {
                        [name: string]: unknown;
                    };
                    content?: never;
                };
                /** @description Conflict */
                409: {
                    headers: {
                        [name: string]: unknown;
                    };
                    content: {
                        "application/json": components["schemas"]["ApiError"] & {
                            /** @constant */
                            code?: "DUPLICATE_NAME_FOUND";
                        };
                    };
                };
                /** @description Unprocessable Content */
                422: {
                    headers: {
                        [name: string]: unknown;
                    };
                    content: {
                        "application/json": components["schemas"]["ApiError"] & {
                            /** @constant */
                            code?: "INAPPROPRIATE_INGREDIENT_NAME";
                        };
                    };
                };
                /** @description Internal Server Error */
                500: {
                    headers: {
                        [name: string]: unknown;
                    };
                    content: {
                        "application/json": components["schemas"]["InternalServerError"];
                    };
                };
            };
        };
    };
}

export interface components {
    schemas: {
        ApiError: {
            code: string;
            message: string;
        };
        InternalServerError: components["schemas"]["ApiError"] & {
            /** @constant */
            code?: "INTERNAL_SERVER_ERROR";
        };
    };
}

生成した型ファイルからエラーコードをユニオン型で取り出すため、いくつかの型ユーティリティをつくります。

/lib/openapi-fetch/types.ts
import type { ClientPathsWithMethod } from "openapi-fetch";

import type { components, paths } from "@/spec/openapi.gen";
import type { fetchClient } from "./client";

// APIエラー
export type ApiError = components["schemas"]["ApiError"]

// リクエストボディ
export type RequestBody<
  Method extends HttpMethod,
  Path extends ClientPathsWithMethod<typeof fetchClient, Method>,
> = paths[Path][Method] extends { requestBody: { content: { "application/json": infer Body } } } ? Body : never;

// エラーコード
export type ErrorCode<
  Method extends HttpMethod,
  Path extends ClientPathsWithMethod<typeof fetchClient, Method>,
> = paths[Path][Method] extends { responses: infer Responses } ? ExtractAllErrorCodes<Responses> : never;

type ExtractAllErrorCodes<Responses> = {
  [Status in keyof Responses]: Status extends ErrorStatus ? ExtractErrorCode<Responses[Status]> : never;
}[keyof Responses];

type ExtractErrorCode<Response> = Response extends { content: { "application/json": infer Content } }
  ? Content extends { code: infer Code }
    ? Code
    : never
  : never;

export type HttpMethod = "get" | "put" | "post" | "delete" | "patch";

export type ErrorStatus = 500 | 501 | 502 | 503 | 504 | 505 | 506 | 507 | 508 | 510 | 511 | '5XX' | 400 | 401 | 402 | 403 | 404 | 405 | 406 | 407 | 408 | 409 | 410 | 411 | 412 | 413 | 414 | 415 | 416 | 417 | 418 | 420 | 421 | 422 | 423 | 424 | 425 | 426 | 427 | 428 | 429 | 430 | 431 | 444 | 450 | 451 | 497 | 498 | 499 | '4XX' | "default";

これらの型ユーティリティを使って、Server Actionにてエラーコードからエラーメッセージへの変換を行います。ポイントとしては、エラーコードのハンドリング漏れが内容に型レベルで制約をかけている点です。

/features/ingredient/actions/create-ingredient-action.ts
import { createIngredient } from "@/features/ingredient/api/create-ingredient";

import type { ApiError, ErrorCode, RequestBody, ResponseData } from "@/lib/openapi-fetch/types";
import type { Result } from "@/types";

export const createIngredientAction = async (
  params: RequestBody<"post", "/ingredients">,
): Promise<Result<ResponseData<"post", "/ingredients">, ApiError>> => {
  // APIリクエストの結果をResult型で受け取る
  const result = await createIngredient(params);

  if (result.type === "failure") {
    // エラーコードをメッセージに変換
    const message = convertErrorCodeToMessage(result.error.code);

    return {
      type: "failure",
      error: {
        code: result.error.code,
        message,
      },
    };
  }

  revalidatePath("/");

  return result;
};

const convertErrorCodeToMessage = (code: ApiError["code"]) => {
  // APIが返す可能性のあるエラーコードのユニオン型にキャスト
  const parsedCode = code as unknown as ErrorCode<"post", "/ingredients">;

  // switch文でユーザーフレンドリーなメッセージに変換
  switch (parsedCode) {
    case "DUPLICATE_NAME_FOUND":
      return "既に登録済みの食材名です";
    case "INAPPROPRIATE_INGREDIENT_NAME":
      return "不適切な食材名が含まれています";
    case "INTERNAL_SERVER_ERROR":
      return "エラーが発生しました。時間をおいて再度お試しください";
    default: {
      // すべてのケースを網羅していることを型レベルでチェック
      const _check: never = parsedCode;

      return "エラーが発生しました。時間をおいて再度お試しください";
    }
  }
};

APIが返す可能性のあるエラーコードをすべてハンドリングしないと、以下のように型エラーが出るので誤りに気づくことができます!

Result型のハンドリングを末端に追いやる

APIを呼び出す関数 (取得系も含む) やServer ActionがすべてResult型を返すとなると、その取り扱いは最終的に末端のコンポーネントに委ねられます。Server Actionからは表示可能なエラーメッセージがResult型で返ってくるというルールを敷くことで、呼び出し側のコンポーネントは try/catch やメッセージの変換を行う必要がなくなりコードが簡潔になります。

"use client";

import { useTransition } from "react";
import { toast } from "react-toastify";

import { createIngredientAction } from "@/features/ingredient/actions/create-ingredient";

export const CreateIngredientButton = () => {
  const [isPending, startTransition] = useTransition();

  const handleClick = () => {
    startTransition(async () => {
      const result = await createIngredientAction({
        name: "Tomato",
      });

      if (result.type === "success") {
        toast.success("食材を登録しました");
      } else {
        // ✨ Server Actionから受け取ったメッセージを表示するだけ
        toast.error(result.error.message);
      }
    });
  };
  return (
    <button type="button" onClick={handleClick} disabled={isPending}>
      食材を登録する
    </button>
  );
};

一方で、取得系の処理はServer Actionによるメッセージの変換を経由しないため、サーバー・クライアントのNetwork Boundaryを意識して、適切にエラーをハンドリングする必要があります。同時に複数のAPIを呼ぶような処理では特にボイラプレートコードが増えがちなので、ユーティリティ関数を用意するのがよいと思いました。

/lib/openapi-fetch/utils.ts
export const parseAsSuccessData = <T, E extends ApiError>(
  result: Result<T, E>,
): T => {
  if (result.type === "failure") {
    throw new Error(result.error.message);
  }
  return result.data;
};
/features/recipe/components/recipe.tsx
export const Recipe = () => {
  const [
    recipeResult,
    ingredientsResult,
  ] = await Promise.all([
    getRecipe(recipeId),
    getIngredientsInRecipe(recipeId),
  ]);

  const recipe = parseAsSuccessData(recipeResult);
  const ingredients = parseAsSuccessData(ingredientsResult);

  return (
  //  ...
  );
};

おわりに

Reactの仕様により、APIエラーの種別に応じた表示分岐を実装しようとすると、半強制的にResult型を導入する必要があることを学びました。試行錯誤中なのでベストな書き方とは言えませんが、エラーを型安全に扱いつつ、Result型の使用範囲を明確に定義できたと思っています。フロントエンドのエラーハンドリングとしてはよくあるパターンなので、何かの参考になれば幸いです。

株式会社FLAT テックブログ

Discussion