neverthrow と fetch と zod を組み合わせた非同期処理【タイムアウト編】


こんにちは。フロントエンドエンジニアの辻です。
Gaji-Labo ではデザイナーとエンジニアが協力して、「手ざわりのいいUI」の実現に向けて、日々励んでいます。
UIの見た目の実装はもちろん大切ですが、UIの裏側で動作するロジックの実装も同じように大切です。
今回も「手ざわりのいいUI」を縁の下から支えるロジック面に関わる内容です。前回に引き続き neverthrow と非同期処理について見ていきます!


さて、前回の記事「neverthrow と fetch と zod を組み合わせた非同期処理【直列実行と並列実行編】」では、neverthrow と fetch と zod を組み合わせて、直列実行と並列実行を試してみました。
今回は、前回に改修した handleFetch 関数にタイムアウトを組み込んでみます。

fetch のタイムアウト

現代フロントエンドにおける fetch のタイムアウト処理は、かなり簡単に実装できるようになりました。
AbortSignal.timeout 関数を fetch の signal パラメータに渡すだけで、タイムアウトを設定できます。

fetch("https://jsonplaceholder.typicode.com/todos/", {
  signal: AbortSignal.timeout(5 * 1000), // 5秒でタイムアウトとする
})
  .then((result) => result.json())
  .then((data) => { console.log(data) })
  .catch((error) => {
    if (error.name === "TimeoutError") {
      // タイムアウトした場合の処理
      console.error("Timeout Error");
    } else {
      console.error("Other Error");
    }
  });

> AbortSignal: timeout() 静的メソッド | MDN Web Docs

タイムアウトを考慮した neverthrow + fetch のサンプルコード

それでは、タイムアウトを考慮して handleFetch 関数を改修してみましょう。
前回のコードから、下記のように改修しました。

import { ResultAsync } from "neverthrow";

/**
 * neverthrow の ResultAsync と fetch を組み合わせた関数です。
 */
export const handleFetch = <R, P = unknown>({
  path,
  method = "GET",
  requestParams,
  timeoutMilliSecond = 30 * 1000, // タイムアウトの時間を追加。デフォルトで 30 秒 …(1)
}: {
  path: string;
  method?: "GET" | "POST" | "PUT" | "DELETE";
  requestParams?: P;
  timeoutMilliSecond?: number;
}): ResultAsync<R, Error> => {
  const params: RequestInit = {
    method,
    signal: AbortSignal.timeout(timeoutMilliSecond), // signal を追加 …(2)
  };
  if ((method === "POST" || method === "PUT") && requestParams) {
    params.body = JSON.stringify(requestParams);
  }

  return ResultAsync.fromPromise(
    fetch(`${path}`, params).then((response) => {
      if (!response.ok) {
        throw new Error("サーバーとの通信に失敗しました");
      }

      return response.json().then((data: R) => {
        const isR = isTypeR<R>(data);
        if (!isR) {
          throw new Error("レスポンスの形式が不正です");
        }
        return data;
      });
    }),
    (error) => {
      if (error instanceof Error) {
        if (error.message === "サーバーとの通信に失敗しました") {}
        if (error.message === "レスポンスの形式が不正です") {}
        if (error.name === "TimeoutError") {
          // タイムアウト時の処理をココに記述する …(3)
          return new Error("サーバーが所定時間内に応答しませんでした");
        }

        return error;
      }
      return new Error("不明なエラーが発生しました");
    }
  );
};

/**
 * fetch の戻り値を推論するための型ガード
 */
const isTypeR = <R>(data: unknown): data is R => {
  return typeof data === "object" && data !== null;
};

改修点は、至ってシンプルです。
まず、タイムアウトの時間 timeoutMilliSecond を新たに引数に設定します。今回はデフォルトで 30 秒としています。(1)
次に、timeoutMilliSecond を fetch のパラメータのsignal に渡します。(2)
これで fetch にタイムアウトを組み込めました。

最後にタイムアウト時のエラーハンドリングを実装します。
…と言っても、今回はただ単純に error.name === "TimeoutError" の条件分岐を追加して、新たにエラーオブジェクトを投げているだけですね。(3)

では、改修した新 handleFetch 関数を呼び出してみましょう。

import { err, ok } from "neverthrow";
import { z, ZodError } from "zod";
import { handleFetch } from "./neverthrow_fetch";

type Todo = {
  id: number;
  userId: number;
  title: string;
  completed: boolean;
};

const run = (id: number) => {
  ok(z.number().min(1).max(100).safeParse(id))
    .andThen((id) => {
      if (!id.success) return err(id.error);
      return ok(id.data);
    })
    .asyncAndThen((id) => {
      return handleFetch<Todo>({
        path: `https://jsonplaceholder.typicode.com/todos/${id}`,
        timeoutMilliSecond: 10 * 1000, // タイムアウトの時間を明示的に設定
      });
    })
    .map((result) => {
      console.log(result);
    })
    .mapErr((error) => {
      if (error instanceof ZodError) {
        console.log("zod エラー", error);
      } else {
        // タイムアウトが発生した場合は「サーバーが所定時間内に応答しませんでした」が出力される
        console.log("API エラー", error);
      }
    });
};

run(1)

handleFetch 関数の呼び出し側から今回の改修点を見てみると、ただ timeoutMilliSecond というオプションパラメータが追加されただけですので、今までと使い勝手は変わりません。
唯一異なる点として mapErr のエラーハンドリング関数にて、タイムアウト時のエラーを扱えるようになった点が挙げられます。
error.message が「サーバーが所定時間内に応答しませんでした」であれば、タイムアウトが発生したと判断できますね。

(サンプルコードのため、エラー判定が粗雑です。本格的に実装する場合は、きちんとエラーオブジェクトを定義すると良いでしょう。)

まとめ

非同期処理に neverthrow を導入すれば、パラメータ検証 → API 実行 → レスポンスの加工 + エラーハンドリング(タイムアウト等のエラーでもなんでも対応可)を1つにまとめられるため、可読性・メンテナンス性が向上します!
また、直列実行であっても並列実行であっても柔軟に対応できます。何でもござれですね!


Gaji-Labo では「手ざわりのよいUI」の実現を、デザイナーとエンジニアが一緒になって目指しています。
カジュアル面談の場も用意しておりますので、「UI コンポーネントの設計・実装が得意!」という方も「業務ロジックの実装が得意!」という方も、ぜひお気軽にご連絡ください!

Gaji-Labo フロントエンドエンジニア向けご案内資料

Gaji-Labo は Next.js, React, TypeScript 開発の実績と知見があります

フロントエンド開発の専門家である私たちが御社の開発チームに入ることで、バックエンドも含めた全体の開発効率が上がります。

「既存のサイトを Next.js に移行したい」
「人手が足りず信頼できるエンジニアを探している」
「自分たちで手を付けてみたがいまいち上手くいかない」

フロントエンド開発に関わるお困りごとがあれば、まずは一度お気軽に Gaji-Labo にご相談ください。

オンラインでのヒアリングとフルリモートでのプロセス支援にも対応しています。

Next.js, React, TypeScript の相談をする!

投稿者 Tsuji Atsuhiro

フロントエンドエンジニア。 DTP・Webデザイナーを経験した後、フロントエンドエンジニアに転向。HTML/CSS/JavaScriptを中心にWeb開発を担当してきました。 UI・UXに興味があり、デザイン・コーディング両面から考えられるデザインエンジニアを目指しています。 普段はマラソンやボクシングなどで体を動かしてます。