ymmr
Recent Articles
    公開日: 2023/12/18

    Axios のレスポンスを Result 型で受け取る

    私は HTTP クライアントとして Axios を好んで使っています。最近エラーハンドリングの手法として、Result 型を使うという選択肢を知りました。この記事では Result 型の説明から始まり、Axios と組み合わせた場合のサンプルコードを紹介します。

    Result 型とは

    処理の結果として、成功と失敗を表現する型です。 Go や Rest には組み込みで存在するらしいのですが、TypeScript では自作またはパッケージを使うことになります。

    // 成功
    type SuccessResult<T> = {
      type: 'success';
      data: T;
    };
    
    // 失敗
    type ErrorResult<E> = {
      type: 'error';
      error: E;
    };
    
    // 成功または失敗
    export type Result<T = unknown, E extends Error = Error> =
      | SuccessResult<T>
      | ErrorResult<E>;
    

    以下のような特徴があります。

    • 成功と失敗を識別するための type プロパティを持つ
    • 処理が成功した場合の返り値の型、または失敗した場合のエラーオブジェクトの型を Generic Parameter として受け取る

    Result 型を使ってみる

    はじめに Result 型を使わない場合のエラーハンドリングを見ていきましょう。

    例えば、ファイルを読み込んでその内容を返す関数があるとします。処理の失敗を表現する方法はいくつかありますが、ここでは例外を発生させるコードを扱います。

    import fs from "fs";
    
    export function readFile(fullPath: string): string {
      // 引数で受け取ったファイルが存在しなければ、例外を発生させる。
      if (!fs.existsSync(fullPath)) {
        throw new Error(
          `ファイルが見つかりませんでした。ファイルパス: ${fullPath}`
        );
      }
      const content = fs.readFileSync(fullPath).toString();
    
      return content;
    }
    
    

    関数 readFile を呼び出す側のコードは次のようになります。

    import { readFile } from "./fs";
    
    // 例外が発生する可能性があるコードを try...catch で囲む。
    try {
      console.log(readFile("./no-such-file.txt"));
    } catch (error: unknown) {
      if (error instanceof Error) {
        // 何らかのエラーハンドリング
        console.log(error.message);
      }
    }
    

    フロントエンドにおいて、このように処理の失敗を例外で表現することはいくつかの辛さを伴います。
    (上記のサンプルがサーバーサイドのもので申し訳ないです 🙏)

    • 型安全ではない (呼び出し側で例外が発生するという、実装の詳細を把握しておく必要がある)
    • 不用意に try...catch で囲むと、意図しない例外の握りつぶしに繋がる
    • そもそも、受け取った例外にあまり関心がない (エラーを記録して、フォールバック UI を表示したいだけのことが多い)

    続いて Result 型を使ったコードを見ていきましょう。

    import fs from "fs";
    
    import type { Result } from "../Result";
    
    export function readFile(fullPath: string): Result<string> {
      if (!fs.existsSync(fullPath)) {
        return {
          type: "error",
          error: new Error(
            `ファイルが見つかりませんでした。ファイルパス: ${fullPath}`
          ),
        };
      }
    
      const content = fs.readFileSync(fullPath).toString();
    
      return {
        type: "success",
        data: content,
      };
    }
    

    呼び出し側のコードは、次のように書き換えることができます。

    import { readFile } from "./fs";
    
    const result = readFile("no-such-file.txt");
    
    if (result.type === "success") {
      console.log(result.data);
    } else {
      // 何らかのエラーハンドリング
      console.error(result.error.message);
    }
    
    

    例外を発生させるコードと比べると、先に挙げた辛さが解消されていることがわかります。

    • 呼び出し側は結果を検証しない限り、データにアクセスできない (型安全)
    • 意図せずに発生した例外は通過する (例外をどう扱うかを処理系に任せる)
    • 処理の成否に集中してコードを書ける

    Axios と Result 型を組み合わせる

    ここからは、この記事の趣旨である "Axios の HTTP レスポンスを Result 型で受け取る" に入っていきます。

    まずは Result 型に HTTP ステータスコードをプロパティとして追加しましょう。

    import type { Result } from '@/types/Result';
    
    export type HttpResult<T = unknown> = Result<T> & {
      // Axios の HTTP エラーを表現する AxiosError は undefined となる可能性がある。
      status: number | undefined;
    };
    

    Axios で post メソッドなどを呼び出すと、HTTP レスポンスとして { data: T, status: number, ... } で表現される AxiosResponse 型のオブジェクトを返します。

    この data を HttpResult 型にしたいのですが、変換するためのコードを毎回書きたくありません。試行錯誤の結果、Axios のラッパークラスをつくるところに落ち着きました。

    import axios, { isAxiosError } from 'axios';
    
    import type { HttpResult } from '@/types/HttpResult';
    import type { AxiosResponse } from 'axios';
    
    export class HttpClient {
      // AxiosResponse を HttpResult に変換するための内部メソッド
      private async handleHttpRequest<T>(
        callback: () => Promise<AxiosResponse<T>>,
      ): Promise<HttpResult<T>> {
        try {
          const response = await callback();
          const { data, status } = response;
    
          // HTTP リクエストが成功した場合は、データを含むオブジェクトを返す。
          return {
            type: 'success',
            data,
            status,
          };
        } catch (error) {
          if (isAxiosError(error)) {
            const { status } = error;
    
          // HTTP リクエストが失敗した場合は、エラーを含むオブジェクトを返す。
            return {
              type: 'error',
              error,
              status,
            };
          }
          
          // 予期しないエラーは通過させる。
          throw error;
        }
      }
    
      // Axios の各 HTTP メソッドに対応したメソッドを定義する。
      async get<T>(url: string): Promise<HttpResult<T>> {
        return this.handleHttpRequest<T>(() => axios.get<T>(url));
      }
    
      async post<T>(url: string, data: T): Promise<HttpResult<T>> {
        return this.handleHttpRequest(() => axios.post<T>(url, data));
      }
    
      async put<T>(url: string, data: T): Promise<HttpResult<T>> {
        return this.handleHttpRequest(() => axios.put<T>(url, data));
      }
    
      async delete<T>(url: string): Promise<HttpResult<T>> {
        return this.handleHttpRequest(() => axios.delete(url));
      }
    }
    

    使い勝手はこんな感じになります。

    import { HttpClient } from '@/lib/axios';
    import { fakeUser } from '@/mocks/fakeUser';
    import { useState } from 'react';
    
    type User = {
      name: string;
    };
    
    const Page = () => {
      const [user, setUser] = useState<User | null>(null);
      const [isLoading, setIsLoading] = useState(false);
      const [error, setError] = useState<Error | null>(null);
    
      const postUser = () => {
        // HttpClient のインスタンスを生成する。
        const http = new HttpClient();
    
        // POST メソッドを呼び出す。
        return http.post<User>(
          'https://jsonplaceholder.typicode.com/users',
          fakeUser,
        );
      };
    
      const handleClick = async () => {
        setIsLoading(true);
    
        const result = await postUser();
        if (result.type === 'success') {
          // 成功の場合はデータをセットする。
          setUser(result.data);
        } else {
          // 失敗んお場合はエラーオブジェクトをセットする。
          setError(result.error);
        }
    
        setIsLoading(false);
      };
    
      if (isLoading) {
        return <div>ユーザーを追加しています。</div>;
      }
    
      if (error) {
        return <div>ユーザーを追加できませんでした。</div>;
      }
    
      return (
        <>
          <div>{user?.name}</div>
          <button type="button" onClick={() => void handleClick()}>
            ユーザーを追加
          </button>
        </>
      );
    };
    
    export default Page;
    

    おわりに

    まだ理解が浅く、"フロントエンド周りのエラーハンドリングはすべて Result 型を採用するべきなのか" と言われると確信を持てていません。しかし、長いこと悩んでいたエラーハンドリングの課題を解決できる案の 1 つであることは間違いなさそうです。個人開発においては、今後このパターンを継続して使っていきたいと思います。

    参考

    私がthrowを使わない理由zenn.dev
    データ取得で try...catch しない理由zenn.dev