0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

フロントエンドのエラーハンドリング、統一できていますか?

Posted at

はじめに

例えばユーザー検索APIを実装するとします。UserRepository.findByIdでDBからユーザーを取得した結果、何も取得できなかった時、この関数の返り値をあなたはどのように実装しますか?

  1. nullを返す(return null
  2. Result型を返す(return { isSuccess: false, errorCode: "not-found" }
  3. 例外を投げる(throw new NotFoundError()

だいたい考えられるのは上記3つかと思います。そして、多くの場合、これらの3つが混在していることが多いのではないでしょうか。

エラーハンドリングの方法が関数によってばらばらだと、コードの複雑性が増し、可読性の低下やバグの増加につながります。

この記事では、「一体どんなエラーハンドリングがベストなのか?」 という疑問について、実際のコードをリファクタリングしながら考えていきます。

今回はフロントエンド側のエラーハンドリングに焦点を当てます。バックエンド側の話は別の機会に取り上げたいと思います。

サンプルコード

今回使用するサンプルコードは以下のリポジトリにあります:

記事の要約

この記事では、フロントエンドにおけるエラーハンドリングの統一的なアプローチを提案します。具体的には以下の5つのリファクタリングステップを通じて、より保守性の高いコードを実現します:

  1. nullfalseを使わない - 意味のない返り値ではなく、カスタムエラークラスによる例外を投げる方式に統一
  2. Result型は使わない - TypeScriptでは例外ベースのエラーハンドリングの方が可読性が高く、実装がシンプル
  3. ひとつのCustomErrorクラスを使い回す - 複数のエラークラスではなく、codeプロパティを持つ単一のCustomErrorクラスで統一し、TypeScriptの型の絞り込みを活用
  4. エラーコードをフロントエンドとBFFで共有する - 変換処理を排除し、最初から統一されたエラーコードを使用
  5. 全てのRepositoryでエラーハンドリング方法を統一する - 実装の一貫性を保ち、可読性と保守性を向上

最終的に、エラーハンドリングをHooks層に集約し、Repository層では例外を投げるだけのシンプルな実装に統一することで、コードの可読性と保守性が大幅に向上します。

ソースコード簡単説明

ezgif-7339fc6925664287.gif

これはユーザーを検索してフォローボタンをクリックできるシンプルなモックアプリケーションのコードです。これをベースにエラーハンドリングについて考えていきましょう。

app/search/page.tsxにフロントエンドの処理が全て記述されています。

重要な部分は、検索ボタンを押した時に走る処理と、フォローボタンを押した時に走る処理です。順に見ていきましょう。

検索ボタンを押した時に走る処理

searchUserRepository
// Repository: ユーザー検索
const searchUserResponseSchema = z.object({
  user: z.object({
    id: z.string(),
    name: z.string(),
  }),
});
async function searchUserRepository(userId: string) {
  const response = await fetch("/api/search/user", {
    method: "POST",
    headers: {
      "Content-Type": "application/json",
    },
    body: JSON.stringify({ userId }),
  });

  if (!response.ok) {
    if (response.status === 400) {
      return false;   // ❌ ぱっと見で「パラメータ不正」を指すとは理解できない
    }
    if (response.status === 404) {
      return null;   // ❌ ぱっと見で「該当ユーザー無し」を指すとは理解できない
    }
    throw new Error("Unknown error");  // ❌ 素のErrorを使用している
  }

  const data = searchUserResponseSchema.parse(await response.json());
  return data.user;
}

リポジトリを見てみると、BFFから返却されたstatus codeをもとに返り値を制御しているようです。

  1. statusが400: falseを返す
  2. statusが404: nullを返す
  3. それ以外: 例外を投げる

この時点で既に汚いコードだと分かりますが、一旦先に進みます。
次に、searchUserRepositoryを利用しているHooksを見てみます。

useUserSearch
// カスタムフック: useUserSearch
function useUserSearch() {
  const [searchError, setSearchError] = useState<string | null>(null);
  const [user, setUser] = useState<{ id: string; name: string } | null>(null);

  const searchUser = async (userId: string) => {
    setSearchError(null);
    setUser(null);

    // catch文によるエラーハンドリング
    try {
      const user = await searchUserRepository(userId);
      if (user === false) {  // ❌ 意味が不明瞭
        setSearchError("パラメータが不正です");
        return;
      }
      if (!user) {   // ❌ 意味が不明瞭
        setSearchError("ユーザーが見つかりません");
        return;
      }
      setUser(user);
    } catch {
      setSearchError("検索に失敗しました");
    }
  };

  return {
    searchUser,
    searchError,
    user,
  };
}

正常系以外の時、searchUserRepositoryからはfalsenull、そして例外が投げられる可能性があるので、それらをもとに、UIに表示するエラーメッセージを出し分けています。

フォローボタンを押した時に走る処理

followUserRepository
// Repository: ユーザーフォロー
// Result型を使って返却する
async function followUserRepository(userId: string) {
  const response = await fetch(`/api/user/${userId}/follow`, {
    method: "POST",
  });
  if (!response.ok) {
    // statusで判定
    if (response.status === 404) {
      return { isSuccess: false, errorCode: "user-not-found" };
    }
    // errorCodeで判定
    const { errorCode } = await response.json();
    if (errorCode === 4001) {
      return { isSuccess: false, errorCode: "invalid-parameter" };
    } else if (errorCode === 4002) {
      return { isSuccess: false, errorCode: "already-followed" };
    } else {
      return { isSuccess: false, errorCode: "follow-failed" };
    }
  }
  return { isSuccess: true };
}

対してフォローボタンを押した時のエラーハンドリングでは、Result型を使用しています。

※Result型とは、処理の結果を型で表現する仕組みです。そもそもTypeScriptにResult型は存在しない上、この記事に登場するResult型は簡易版です。TypeScriptにResult型を導入する記事はweb上にたくさん転がっているので、詳しくはそちらを参考にしてください。

useFollowUser
// カスタムフック: useFollowUser
function useFollowUser() {
  const [followError, setFollowError] = useState<string | null>(null);

  const followUser = async (userId: string) => {
    setFollowError(null);

    // Result型によるエラーハンドリング
    const result = await followUserRepository(userId);
    if (!result.isSuccess) {   // ❌ 毎回isSuccessチェックが必要
      switch (result.errorCode) {
        case "user-not-found":
          setFollowError("ユーザーが見つかりません");
          break;
        case "already-followed":
          setFollowError("既にフォロー済みです");
          break;
        case "invalid-parameter":
          setFollowError("パラメータが不正です");
          break;
        case "follow-failed":
        default:
          setFollowError("フォローに失敗しました");
          break;
      }
    }
  };

  return {
    followUser,
    followError,
  };
}

hooks内では、errorCodeをもとにしたswitch構文でUI表示を制御しています。

リファクタリングその1: nullfalseは使わない

まずnullfalseは使わないでください。話はその後です。

nullfalse?これらの値には何の情報も含まれていません。パッと見ただけで「nullってことは対応するユーザー情報がないんだな」「falseってことは検索クエリが不正ってことか」と理解できる人がいるでしょうか。「あれ?検索クエリ不正を表してるのはfalseだっけ?nullだっけ?」となるのが関の山です。

searchUserRepositoryのケースでは、全て例外を投げる方式に統一しましょう。

ここでポイントは、カスタムエラークラスを定義することです。素のErrorクラスを使用すると、それが何のエラーなのか区別することが難しくなります。

errors.ts
// カスタムエラークラス

export class BadRequestError extends Error {
  constructor(message: string = "Bad Request") {
    super(message);
    this.name = "BadRequestError";
  }
}

export class NotFoundError extends Error {
  constructor(message: string = "Not Found") {
    super(message);
    this.name = "NotFoundError";
  }
}

export class UnexpectedError extends Error {
  constructor(message: string = "Unexpected Error") {
    super(message);
    this.name = "UnexpectedError";
  }
}

上記のようにカスタムエラークラスを定義すると、catchしたerrorinstanceofを用いて判別することができるようになります。

リファクタリング結果

ということで、リファクタリングその1: nullfalseは使わないです。

searchUserRepository
async function searchUserRepository(
  userId: string
) {
  const response = await fetch("/api/search/user", {
    method: "POST",
    headers: {
      "Content-Type": "application/json",
    },
    body: JSON.stringify({ userId }),
  });

  if (!response.ok) {
    if (response.status === 400) {
-      return false;
+      throw new BadRequestError("パラメータが不正です");   // ✅ 明示的
    }
    if (response.status === 404) {
-      return null;
+      throw new NotFoundError("ユーザーが見つかりません");   // ✅ 明示的
    }
-   throw new Error("Unknown error");
+   throw new UnexpectedError("検索に失敗しました");   // ✅ 明示的
  }

  const data = searchUserResponseSchema.parse(await response.json());
  return data.user;
}
useUserSearch
// カスタムフック: useUserSearch
function useUserSearch() {
  const [searchError, setSearchError] = useState<string | null>(null);
  const [user, setUser] = useState<{ id: string; name: string } | null>(null);
  const searchUser = async (userId: string) => {
    setSearchError(null);
    setUser(null);
    // catch文によるエラーハンドリング
    try {
      const user = await searchUserRepository(userId);
-      if (user === false) {
+      setUser(user);
+    } catch (error) {
+      if (error instanceof BadRequestError) {   // ✅ 明示的
        setSearchError("パラメータが不正です");
-        return;
-      }
-      if (!user) {
+      } else if (error instanceof NotFoundError) {   // ✅ 明示的
        setSearchError("ユーザーが見つかりません");
-        return;
+      } else {
+        setSearchError("検索に失敗しました");
      }
-      setUser(user);
-    } catch {
-      setSearchError("検索に失敗しました");
    }
  };

  return {
    searchUser,
    searchError,
    user,
  };
}

リファクタリングその2: Result型は使わない

TypeScriptでResult型を導入するメリットはあまりない気がします。確かに型安全なエラーハンドリングを実現できる(例外を投げるエラーハンドリングだと、catch(e)eunknownになってしまう)という利点はあるのですが、それを上回る以下のデメリットがあるように思います。

  1. 正常系のデータを取得するために、毎回isSuccessの確認が必要になる
  2. 新たにビジネスロジックを管理する層を追加した時に、無駄な実装が増える

正常系のデータを取得するために、毎回isSuccessの確認が必要になる

サンプルコードで説明します。以下は1+2を計算した後、その結果から2を引いた値をconsole.logで表示しています。

function add(x: number, y: number | null) {
  if (y === null) {
    return { isSuccess: false };
  }
  return { isSuccess: true, data: x + y };
}
function minus(x: number, y: number | null) {
  if (y === null) {
    return { isSuccess: false };
  }
  return { isSuccess: true, data: x - y };
}

function main() {
  try {
    const addRes = add(1, 2);
    if (!addRes.isSuccess) {   // ❌ 検証1回目
      throw new Error();
    }
    const addResult = addRes.data;
    const minusRes = minus(addResult, 2);
    if (!minusRes.isSuccess) {   // ❌ 検証2回目
      throw new Error();
    }
    const minusResult = minusRes.data;
    console.log(minusResult);
  } catch (error) {
    console.error(error);
  }
}

返ってきたResult型から値を取り出すには、毎回isSuccessを確認する必要があります。これが非常にめんどくさいです。余分なコードが増えて可読性が悪くなります。

例外を投げる方式なら、add関数やminus関数の返り値をいちいち検証する必要がありません。異常系は全てcatchに流れてくれます。

function add(x: number, y: number | null) {
  if (y === null) {
    throw new Error();
  }
  return x + y;
}
function minus(x: number, y: number | null) {
  if (y === null) {
    throw new Error();
  }
  return x - y;
}

function main() {
  try {
    const addResult = add(1, 2);  // ✅ 検証不要
    const minusResult = minus(addResult, 2); // ✅ 検証不要
    console.log(minusResult);
  } catch (error) {
    console.error(error);
  }
}

確かにcatchに流れたerrorunknownになりますが、上記の通りCustomErrorを使用する運用を徹底していれば十分に快適な開発体験が得られるはずです。

新たにビジネスロジックを管理する層を追加した時に、無駄な実装が増える

この記事では、ビジネスロジックはHooksに含めています。しかし、大規模な開発になると、コードの再利用や、テストの容易性を上げるために、Hooksとビジネスロジックを切り分けて実装したくなることがあります。

その場合、データのフローは1層増えて、
UI <-> Hooks <-> Business Logic <-> Repository
となります。

Result型を用いると、Repositoryで返したResult型をBusiness Logicで検証して、またまたResult型をHooksに返し、検証して...と、if(!isSuccess) thenの構文が至るところで発生することになり、非常に可読性が低くなります。

function repository(param: number | null) {
  if (param === null) return { isSuccess: false, error: "not-found" };
  if (param === 0) return { isSuccess: false, error: "invalid-param" };
  return { isSuccess: true, data: param };
}

function businessLogic(param: number | null) {
  const res = repository(param);
  if (!res.isSuccess) {   // ❌ 検証1回目
    switch (res.error) {
      case "not-found":
        return { isSuccess: false, error: "NOT_FOUND" };
      case "invalid-param":
        return { isSuccess: false, error: "INVALID_PARAM" };
      default:
        return { isSuccess: false, error: "UNEXPECTED_ERROR" };
    }
  }
  return { isSuccess: true, data: param };
}

function useHook() {
  function hoge(param: number | null) {
    const res = businessLogic(param);
    if (!res.isSuccess) {  // ❌ 同じような検証をHooks内でも記述する必要がある
      switch (res.error) {
        case "NOT_FOUND":
          // エラー処理
          break;
        case "INVALID_PARAM":
          // エラー処理
          break;
        default:
          // エラー処理
          break;
      }
    }
    const data = res.data;
    // 正常系の処理
  }
  return { hoge };
}

例外を投げる方式であれば、「エラーハンドリングはHooksのみで行う。ビジネスロジックではそれを行わない」と決めることで無駄な実装を減らすことができます。

function repository(param: number | null) {
  if (param === null) throw new NotFoundError();
  if (param === 0) throw new InvalidParamError();
  return param;
}

function businessLogic(param: number | null) {
  const res = repository(param);  // ✅ そのまま通す。正常系のハンドリングのみ
  return res;
}

function useHook() {
  function hoge(param: number | null) {
    try {
      const res = businessLogic(param);
      // ✅ 正常系の処理に集中
      const data = res;
    } catch (error) {
      // ✅ エラーハンドリングはここだけ
      if (error instanceof NotFoundError) {
        // エラー処理
      } else if (error instanceof InvalidParamError) {
        // エラー処理
      } else {
        // エラー処理
      }
    }
  }
  return { hoge };
}

以上の理由から、followUserRepositoryからResult型を削除し、try catchによるエラーハンドリングを導入しましょう。

リファクタリング結果

error.ts
// カスタムエラークラス
export class BadRequestError extends Error {
  constructor(message: string = "Bad Request") {
    super(message);
    this.name = "BadRequestError";
  }
}
export class NotFoundError extends Error {
  constructor(message: string = "Not Found") {
    super(message);
    this.name = "NotFoundError";
  }
}
export class UnexpectedError extends Error {
  constructor(message: string = "Unexpected Error") {
    super(message);
    this.name = "UnexpectedError";
  }
}

+ export class BadRequestAlreadyFollowedError extends Error {
+   constructor(message: string = "Bad Request Already Followed") {
+     super(message);
+     this.name = "BadRequestAlreadyFollowedError";
+   }
+ }
followUserRepository
async function followUserRepository(userId: string) {
  const response = await fetch(`/api/user/${userId}/follow`, {
    method: "POST",
  });
  if (!response.ok) {
    // statusで判定
    if (response.status === 404) {
-      return { isSuccess: false, errorCode: "user-not-found" };    
+      throw new NotFoundError();
    }
    // errorCodeで判定
    const { errorCode } = await response.json();
-    if (errorCode === 4001) {
-       return { isSuccess: false, errorCode: "invalid-parameter" };
-    } else if (errorCode === 4002) {
-       return { isSuccess: false, errorCode: "already-followed" };
-    } else {
-       return { isSuccess: false, errorCode: "follow-failed" };
+    if (errorCode === 4001) {
+       throw new BadRequestError();
+    } else if (errorCode === 4002) {
+       throw new BadRequestAlreadyFollowedError();
+    } else {
+       throw new UnexpectedError();    
     }
  }
-   return { isSuccess: true };  
}
followUser
  const followUser = async (userId: string) => {
    setFollowError(null);
-     const result = await followUserRepository(userId);
-     if (!result.isSuccess) {
-       switch (result.errorCode) {
-         case "user-not-found":
-           setFollowError("ユーザーが見つかりません");
-           break;
-         case "already-followed":
-           setFollowError("既にフォロー済みです");
-           break;
-         case "invalid-parameter":
-           setFollowError("パラメータが不正です");
-           break;
-         case "follow-failed":
-         default:
-           setFollowError("フォローに失敗しました");
-           break;
+     try {
+       // catch文によるエラーハンドリング
+       await followUserRepository(userId);
+     } catch (error) {
+       if (error instanceof BadRequestAlreadyFollowedError) {
+         setFollowError("既にフォロー済みです");
+       } else if (error instanceof BadRequestError) {
+         setFollowError("パラメータが不正です");
+       } else if (error instanceof NotFoundError) {
+         setFollowError("ユーザーが見つかりません");
+       } else {
+         setFollowError("フォローに失敗しました");
+       }
     }
   };

リファクタリングその3: ひとつのCustomErrorクラスを使い回す

上記では、BadRequestError, NotFoundErrorなど、複数のカスタムエラークラスを定義することで、instanceofによる比較ができるようになりました。しかし、この方法にはひとつ弱点があります。switch構文による型の絞り込みができないのです。

switch構文による型の絞り込みとは、union型で定義された変数の型をTypeScriptが自動で絞り込む仕組みであり、JavaScriptではなくTypeScriptを用いるメリットの一つです。
switch.gif

そこで、これらのカスタムエラークラスをひとつに統合し、switch構文で絞り込めるようにします。CustomErrorを定義し、codeというクラス内変数を持たせることで何のエラーなのかがわかるようにします。code"bad-request", "not-found"などの値を取るunion型です。

error.ts
export const errorCode = [
  // common error codes
  "bad-request",
  "not-found",
  "unexpected-error",
  "bad-request/already-followed", 
] as const;

export type ErrorCode = (typeof errorCode)[number];

export class CustomError extends Error {
  code: ErrorCode;
  constructor(code: ErrorCode, message?: string) {
    super(message ?? "CustomError");
    this.name = "CustomError";
    this.code = code;
  }
}

これを用いることで、instanceofで比較していたif構文をswitch構文に変更でき、型補完の恩恵を受けられるようになりました。

リファクタリング結果

searchUserRepository
async function searchUserRepository(
  userId: string
) {
  const response = await fetch("/api/search/user", {
    method: "POST",
    headers: {
      "Content-Type": "application/json",
    },
    body: JSON.stringify({ userId }),
  });

  if (!response.ok) {
    if (response.status === 400) {
-       throw new BadRequestError("パラメータが不正です");
+       throw new CustomError("bad-request", "パラメータが不正です");
    }
    if (response.status === 404) {
-       throw new NotFoundError("ユーザーが見つかりません");
+       throw new CustomError("not-found", "ユーザーが見つかりません");
    }
-     throw new UnexpectedError("検索に失敗しました");
+     throw new CustomError("unexpected-error", "検索に失敗しました");
  }

  const data = searchUserResponseSchema.parse(await response.json());
  return data.user;
}
followUserRepository
async function followUserRepository(userId: string) {
  const response = await fetch(`/api/user/${userId}/follow`, {
    method: "POST",
  });
  if (!response.ok) {
    // statusで判定
    if (response.status === 404) {
-       throw new NotFoundError();
+       throw new CustomError("not-found");
    }
    // errorCodeで判定
    const { errorCode } = await response.json();
-    if (errorCode === 4001) {
-       throw new BadRequestError();
-    } else if (errorCode === 4002) {
-       throw new BadRequestAlreadyFollowedError();
-    } else {
-       throw new UnexpectedError();
+    if (errorCode === 4001) {
+       throw new CustomError("bad-request");
+    } else if (errorCode === 4002) {
+       throw new CustomError("bad-request/already-followed");
+    } else {
+       throw new CustomError("unexpected-error");
     }
  }
}
useUserSearch
function useUserSearch() {
  const [searchError, setSearchError] = useState<string | null>(null);
  const [user, setUser] = useState<{ id: string; name: string } | null>(null);
  const searchUser = async (userId: string) => {
    setSearchError(null);
    setUser(null);
    try {
      const user = await searchUserRepository(userId);
      setUser(user);
    } catch (error) {
-       if (error instanceof BadRequestError) {
-         setSearchError("パラメータが不正です");
-       } else if (error instanceof NotFoundError) {
-         setSearchError("ユーザーが見つかりません");
-       } else {
-         setSearchError("検索に失敗しました");
+       if (error instanceof CustomError) {
+         switch (error.code) {
+           case "bad-request":
+             setSearchError("パラメータが不正です");
+             return;
+           case "not-found":
+             setSearchError("ユーザーが見つかりません");
+             return;
+         }
      }
+       setSearchError("検索に失敗しました");
    }
  };
useFollowUser
function useFollowUser() {
  const [followError, setFollowError] = useState<string | null>(null);
  const followUser = async (userId: string) => {
    setFollowError(null);
    try {
      // catch文によるエラーハンドリング
      await followUserRepository(userId);
    } catch (error) {
-       if (error instanceof BadRequestAlreadyFollowedError) {
-         setFollowError("既にフォロー済みです");
-       } else if (error instanceof BadRequestError) {
-         setFollowError("パラメータが不正です");
-       } else if (error instanceof NotFoundError) {
-         setFollowError("ユーザーが見つかりません");
-       } else {
-         setFollowError("フォローに失敗しました");
+       if (error instanceof CustomError) {
+         switch (error.code) {
+           case "bad-request/already-followed":
+             setFollowError("既にフォロー済みです");
+             return;
+           case "bad-request":
+             setFollowError("パラメータが不正です");
+             return;
+           case "not-found":
+             setFollowError("ユーザーが見つかりません");
+             return;
+         }
      }
+       setFollowError("フォローに失敗しました");
    }
  };

リファクタリングその4: エラーコードをフロントエンドとBFFで共有する

「リファクタリングその3」でエラーコードを整理しました。

export const errorCode = [
  // common error codes
  "bad-request",
  "not-found",
  "unexpected-error",
  "bad-request/already-followed", 
] as const;

フロントエンドでは、このエラーコードに基づいてUIの表示を制御することになりました。大分スッキリしてきましたね。もう一歩先に進めることで、もっとエラーハンドリングをシンプルにしていきましょう。

次に目を向けるのは、フロントエンドのrepositoryとBFFの連結部分です。followUserRepositoryを見てみると、BFFからは40014002など、BFF独自で定義されたerrorCodeが返ってきています。

followUserRepository
async function followUserRepository(userId: string) {
  const response = await fetch(`/api/user/${userId}/follow`, {
    method: "POST",
  });
  if (!response.ok) {
    // statusで判定
    if (response.status === 404) {
      throw new CustomError("not-found");
    }
    // errorCodeで判定
    const { errorCode } = await response.json();
    if (errorCode === 4001) {   // ❌ BFF独自のコード
      throw new CustomError("bad-request");
    } else if (errorCode === 4002) {   // ❌ BFF独自のコード
      throw new CustomError("bad-request/already-followed");
    } else {
      throw new CustomError("unexpected-error");
    }
  }
}

フロントエンドとBFFで異なるerrorCodeを使う理由はあるでしょうか。BFFはその名の通りBackend for Frontendなわけですから、Frontendに都合が良いように実装されるものだと思います。「BFFから返ってきたerrorCodeをフロントエンド用に変換して、、、」といった無駄な処理を挟むくらいなら、いっそ最初から変換しなくて良いように実装するべきです。ということで、BFFから返ってくるerrorCodeはフロントエンドで定義したものと同じものを使うことにします。

リファクタリング結果

searchUserRepository
- const searchUserResponseSchema = z.object({
-   user: z.object({
-     id: z.string(),
-     name: z.string(),
-   }),
+ const searchUserResponseSchema = z.union([
+   z.object({
+     isSuccess: z.literal(true),
+     user: z.object({
+       id: z.string(),
+       name: z.string(),
+     }),
+   }),
- });
+   z.object({
+    isSuccess: z.literal(false),
+     errorCode: z.enum(errorCode),   // ✅ フロントエンドと同じerrorCode
+   }),
+ ]);
async function searchUserRepository(userId: string) {
  const response = await fetch("/api/search/user", {
    method: "POST",
    headers: {
      "Content-Type": "application/json",
    },
    body: JSON.stringify({ userId }),
  });

-   if (!response.ok) {
-     if (response.status === 400) {
-       throw new CustomError("bad-request", "パラメータが不正です");
-     }
-     if (response.status === 404) {
-       throw new CustomError("not-found", "ユーザーが見つかりません");
-     }
-     throw new CustomError("unexpected-error", "検索に失敗しました");
-   }

  const data = searchUserResponseSchema.parse(await response.json());
+   if (!data.isSuccess) {
+     throw new CustomError(data.errorCode);   // ✅ 変換不要
+   }
  return data.user;
}
followUserRepository
async function followUserRepository(userId: string) {
  const response = await fetch(`/api/user/${userId}/follow`, {
    method: "POST",
  });
  if (!response.ok) {
+     const { errorCode } = await response.json();
+     if (errorCode) {
+       throw new CustomError(errorCode as ErrorCode);
+     }
    if (response.status === 404) {
      throw new CustomError("not-found");
    }
-     // errorCodeで判定
-     const { errorCode } = await response.json();
-     if (errorCode === 4001) {
-       throw new CustomError("bad-request");
-     } else if (errorCode === 4002) {
-       throw new CustomError("bad-request/already-followed");
-     } else {
-       throw new CustomError("unexpected-error");
-     }
+     throw new CustomError("unexpected-error");
  }
}

リファクタリングその5: searchUserRepositoryfollowUserRepositoryのエラーハンドリング方法を統一する

最後!!ここまでくればあと一歩です。

searchUserRepositoryfollowUserRepositoryでエラーハンドリングの方法が異なってます。前者はBFFから返ってきたerrorCodeに基づくハンドリング、後者はstatus codeに基づくハンドリングです。

どちらもerrorCodeに基づくハンドリングに統一しておきましょう。全てのRepositoryで実装方式を揃えておくことで、可読性を上げることができますし、実装の時に毎回悩まず済みます。

リファクタリング結果

followUserRepository
+ const followUserResponseSchema = z.union([
+   z.object({
+     isSuccess: z.literal(true),
+   }), 
+   z.object({
+     isSuccess: z.literal(false),
+     errorCode: z.enum(errorCode),
+   })
+ ]);
async function followUserRepository(userId: string) {
  const response = await fetch(`/api/user/${userId}/follow`, {
    method: "POST",
  });
-   if (!response.ok) {
-     const { errorCode } = await response.json();
-     if (errorCode) {
-       throw new CustomError(errorCode as ErrorCode);
-     }
-     // errorCodeがない場合のフォールバック
-     if (response.status === 404) {
-       throw new CustomError("not-found");
-     }
-     throw new CustomError("unexpected-error");
+ 
+   const data = followUserResponseSchema.parse(await response.json());
+   if (!data.isSuccess) {
+     throw new CustomError(data.errorCode);
   }
}

結果

お疲れ様です。最後にリファクタリングしたsearchUserRepositoryfollowUserRepositoryuseUserSearch, useFollowUserを眺めてみましょう。

searchUserRepository
const searchUserResponseSchema = z.union([
  z.object({
    isSuccess: z.literal(true),
    user: z.object({
      id: z.string(),
      name: z.string(),
    }),
  }),
  z.object({
    isSuccess: z.literal(false),
    errorCode: z.enum(errorCode),
  }),
]);
async function searchUserRepository(userId: string) {
  const response = await fetch("/api/search/user", {
    method: "POST",
    headers: {
      "Content-Type": "application/json",
    },
    body: JSON.stringify({ userId }),
  });

  const data = searchUserResponseSchema.parse(await response.json());
  if (!data.isSuccess) {
    throw new CustomError(data.errorCode);   // ✅ 変換不要
  }
  return data.user;
}
followUserRepository
const followUserResponseSchema = z.union([
  z.object({
    isSuccess: z.literal(true),
  }), 
  z.object({
    isSuccess: z.literal(false),
    errorCode: z.enum(errorCode),
  })
]);
async function followUserRepository(userId: string) {
  const response = await fetch(`/api/user/${userId}/follow`, {
    method: "POST",
  });
  
  const data = followUserResponseSchema.parse(await response.json());
  if (!data.isSuccess) {
    throw new CustomError(data.errorCode);   // ✅ 変換不要
  }
}
useUserSearch
function useUserSearch() {
  const [searchError, setSearchError] = useState<string | null>(null);
  const [user, setUser] = useState<{ id: string; name: string } | null>(null);

  const searchUser = async (userId: string) => {
    setSearchError(null);
    setUser(null);

    try {
      const user = await searchUserRepository(userId);
      setUser(user);
    } catch (error) {
      // ✅ エラーハンドリングを集約
      if (error instanceof CustomError) {
        switch (error.code) {
          case "bad-request":  // ✅ 共通化したerrorCode
            setSearchError("パラメータが不正です");
            return;
          case "not-found": // ✅ 共通化したerrorCode
            setSearchError("ユーザーが見つかりません");
            return;
        }
      }
      setSearchError("検索に失敗しました");
    }
  };

  return {
    searchUser,
    searchError,
    user,
  };
}
useFollowUser
function useFollowUser() {
  const [followError, setFollowError] = useState<string | null>(null);

  const followUser = async (userId: string) => {
    setFollowError(null);

    try {
      // catch文によるエラーハンドリング
      await followUserRepository(userId);
    } catch (error) {
      // ✅ エラーハンドリングを集約
      if (error instanceof CustomError) {
        switch (error.code) {
          case "bad-request/already-followed":  // ✅ 共通化したerrorCode
            setFollowError("既にフォロー済みです");
            return;
          case "bad-request":  // ✅ 共通化したerrorCode
            setFollowError("パラメータが不正です");
            return;
          case "not-found":  // ✅ 共通化したerrorCode
            setFollowError("ユーザーが見つかりません");
            return;
        }
      }
      setFollowError("フォローに失敗しました");
    }
  };

  return {
    followUser,
    followError,
  };
}

ざっくり変更点としては、

  • エラーハンドリング方法を、例外を投げる方法に統一した
  • エラーハンドリングをHooksに集約した
  • errorCodeをフロントエンドとBFFで同じものを使うようにした

これによって、以下のメリットが得られます:

  1. 一貫性の向上: 全てのRepositoryで同じエラーハンドリングパターンを使用することで、コードの予測可能性が高まり、新しい開発者でも理解しやすくなります。

  2. 可読性の向上: nullfalseのような意味のない返り値がなくなり、例外による明示的なエラー処理により、コードの意図が明確になります。

  3. 保守性の向上: エラーハンドリングがHooks層に集約されているため、エラー処理の変更が必要な場合でも、修正箇所が限定的になります。

  4. 型安全性の向上: TypeScriptのswitch構文による型の絞り込みを活用することで、エラーコードの型チェックが効き、コンパイル時にエラーを検出できます。

  5. 実装の簡素化: BFFとフロントエンドでエラーコードを共有することで、変換処理が不要になり、コードがシンプルになります。

  6. 拡張性の向上: 新しいエラーコードを追加する際も、errorCode配列に追加するだけで、型システムが自動的に反映されます。

まとめ

この記事では、フロントエンドにおけるエラーハンドリングを統一するための5つのリファクタリングステップを紹介しました。

最初のコードでは、nullfalseを返す関数、Result型を使う関数、例外を投げる関数が混在しており、エラーハンドリングの方法が統一されていませんでした。これにより、コードの複雑性が増し、可読性や保守性が低下していました。

リファクタリングを通じて、以下の原則に基づいた統一的なアプローチを実現しました:

  • 例外ベースのエラーハンドリング: nullfalseのような意味のない返り値ではなく、カスタムエラークラスによる例外を投げる方式に統一
  • 単一のエラークラス: 複数のエラークラスではなく、codeプロパティを持つCustomErrorクラスで統一し、TypeScriptの型の絞り込みを活用
  • エラーハンドリングの集約: エラーハンドリングをHooks層に集約し、Repository層では例外を投げるだけのシンプルな実装に統一
  • エラーコードの共有: フロントエンドとBFFで同じエラーコードを使用することで、変換処理を排除

この統一的なアプローチにより、コードの可読性、保守性、型安全性が大幅に向上し、チーム開発においても一貫性のあるコードベースを維持できるようになりました。

エラーハンドリングは、コードの品質に大きく影響する重要な要素です。この記事で紹介したアプローチを参考に、あなたのプロジェクトでも統一的なエラーハンドリングを実現してみてください。

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?