neverthrow と fetch と zod を組み合わせた非同期処理【直列実行と並列実行編】


こんにちは。フロントエンドエンジニアの辻です。
Gaji-Labo ではデザイナーとエンジニアが協力して、「手ざわりのいいUI」の実現に向けて、日々励んでいます。
今回も「手ざわりのいいUI」を縁の下から支えるロジックにまつわる解説記事です。前回の記事「neverthrow と fetch と zod を組み合わせた非同期処理」の続編にあたります。

さて、前回の記事では、neverthrow の紹介からはじまり、fetch と zod を組み合わせたコードを紹介しました。
今回は、前回に作成した handleFetch 関数を改修しつつ、いくつか使用例を見ていこうと思います。

handleFetch の改修

まずは前回に作成した handleFetch 関数を改修してみましょう。

前回の記事にあった「分かりやすさを優先して Promise<Result<T, E>> としていますが、開発時は ResultAsync<T, E>とした方がより効率的」を考慮して、改修してみます。

import { ResultAsync } from "neverthrow";

/**
 * neverthrow の ResultAsync と fetch を組み合わせた関数です。
 */
export const handleFetch = <R, P = unknown>({
  path,
  method = "GET",
  requestParams,
}: {
  path: string;
  method?: "GET" | "POST" | "PUT" | "DELETE";
  requestParams?: P;
}): ResultAsync<R, Error> => {
  const params: RequestInit = {
    method,
  };
  if ((method === "POST" || method === "PUT") && requestParams) {
    params.body = JSON.stringify(requestParams);
  }

  // ok(), err() から ResultAsync.fromPromise() へ移行…(1)
  return ResultAsync.fromPromise(
    // 第一引数: 非同期処理の関数 …(2)
    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;
      });
    }),
    // 第二引数: 第一引数の非同期処理のエラーハンドリング関数 …(3)
    (error) => {
      if (error instanceof Error) {
        if (error.message === "サーバーとの通信に失敗しました") {
          // 通信失敗時の処理
        }
        if (error.message === "レスポンスの形式が不正です") {
          // レスポンスが不正な場合の処理
        }

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

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

前回の handleFetch 関数と大きく異なっているのは ResultAsync.fromPromise を利用している点です。(1)
ResultAsync.fromPromise の第一引数には非同期処理の関数をセットします。(2)
そして、第二引数には第一引数の関数でエラーが発生した場合のエラーハンドリング関数をセットします。(3)

ResultAsync.fromPromise を利用することで、handleFetch 関数は ResultAsync<R, Error> を返すようになりました。
これで handleFetch 関数の呼び出し側で fromPromise 関数を、都度呼び出す必要もなくなります。

複数の非同期処理を直列で実行する

さっそく新 handleFetch 関数を使ってみましょう。
まずは非同期処理を直列で実行するケースを考えてみます。
あるAPIを実行し、その結果を元に別のAPIを実行する…といったように API を直列で実行したいケースってよくあると思います。
この場合の 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 runSeries = (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}`,
      });
    })
    .andThen((result) => {
      console.log("id: ", result);

      return handleFetch<Todo>({
        path: `https://jsonplaceholder.typicode.com/todos/${result.id + 10}`,
      });
    })
    .andThen((result) => {
      console.log("id + 10: ", result);

      return handleFetch<Todo>({
        path: `https://jsonplaceholder.typicode.com/todos/${result.id + 20}`,
      });
    })
    .andThen((result) => {
      console.log("id + 20: ", result);

      return handleFetch<Todo>({
        path: `https://jsonplaceholder.typicode.com/todos/${result.id + 30}`,
      });
    })
    .map((result) => {
      console.log("id + 30: ", result);
    })
    .mapErr((error) => {
      if (error instanceof ZodError) {
        console.log("zod エラー", error);
      } else {
        console.log("API エラー", error);
      }
    });
};

runSeries(1);

実際に実行してみると、ログが順々に表示されますね。

ネットワークをのぞいてみると、確かに直列で実行されています。

直列実行における handleFetch の使い方は、至ってシンプルです。
andThen (asyncAndThen) を連結して、ResultAsync オブジェクトを返していくだけです。fetch における then と同じですね。
留意点として、ResultAsync オブジェクトを扱う場合、asyncAndThen を一回挟んでしまえば、後続は andThen でつなげられます。
ok().asyncAndThen().asyncAndThen() のようにせずとも大丈夫です。

neverthrow ver7.0.0 の src/result-async.ts を見てみると、下記のような記述がありますね。
ResultAsync 型の andThen は、Result オブジェクトと ResultAsync オブジェクトを柔軟に吸収して処理を続けられるようになっています。

export class ResultAsync<T, E> implements PromiseLike<Result<T, E>> {
  // …略…

  andThen<R extends Result<unknown, unknown>>(
    f: (t: T) => R,
  ): ResultAsync<InferOkTypes<R>, InferErrTypes<R> | E>
  andThen<R extends ResultAsync<unknown, unknown>>(
    f: (t: T) => R,
  ): ResultAsync<InferAsyncOkTypes<R>, InferAsyncErrTypes<R> | E>
  andThen<U, F>(f: (t: T) => Result<U, F> | ResultAsync<U, F>): ResultAsync<U, E | F>

  // …略…
}

> src/result-async.ts | neverthrow ver7.0.0

複数の非同期処理を並列で実行する

先程は直列で非同期処理を実行しましたが、今度は並列で実行してみましょう。
neverthrow で複数の ResultAsync オブジェクトをまとめる場合は、combine か combineWithAllErrors を利用します。
combine と combineWithAllErrors の本来の目的は、複数の ResultAsync オブジェクトを1つにまとめる事ですが、実質的に非同期処理の並列実行が可能になります。

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

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

const runParallel = (ids: number[]) => {
  ok(z.array(z.number().min(1).max(100)).min(1).safeParse(ids))
    .andThen((ids) => {
      if (!ids.success) return err(ids.error);
      return ok(ids.data);
    })
    .asyncAndThen((ids) => {
      const fetches = ids.map((id) => {
        return handleFetch<Todo>({
          path: `https://jsonplaceholder.typicode.com/todos/${id}`,
        });
      });

      return ResultAsync.combine(fetches);
    })
    .map((results) => {
      results.map((todo) => {
        console.log("todo: ", todo);
      });
    })
    .mapErr((error) => {
      if (error instanceof ZodError) {
        console.log("zod エラー", error);
      } else {
        console.log("API エラー", error);
      }
    });
};

runParallel([1, 2, 3, 4]);

実際に実行してみると、ログは id 順に表示されていますが…

ネットワークをのぞいてみると、並列で実行されていますね。

今回利用した combine は、複数の ResultAsync オブジェクトの全てが Ok である場合は、その配列をラップした Ok を返します。逆に Err が存在する場合は、一番はじめの Err だけを返します。
もし、すべての Err を返してほしいのであれば combine の代わりに combineWithAllErrors を利用します。

ちなみに今回使った combine ですが、Result 型にも静的メソッドとして用意されています。(combineWithAllErrors も同様に用意されています。)
Result 型であっても ResultAsync 型であっても、combine と combineWithAllErrors の使い方は同じです。

注意点としては、combine と combineWithAllErrors の引数には必ず、Result 型のみの配列か、ResultAsync 型のみの配列をセットする必要があります。
Result 型と ResultAsync 型がごちゃ混ぜになった配列をセットすることはできません。

まとめ

今回は neverthrow と fetch と zod を組み合わせた handleFetch 関数を駆使して、非同期処理の直列実行と並列実行を試してみました。

非同期処理に neverthrow を導入すれば、直列実行であっても並列実行であっても、パラメータ検証 → API 実行 → レスポンスの加工 + エラーハンドリングを1つにまとめられるため、可読性・メンテナンス性が向上します!
「try ~ catch 構文により、どこで処理しているのか分からなくなった…」ってケースを防げますね。

複雑な非同期処理には、ぜひ neverthrow の導入を検討してみてはいかがでしょうか?

Gaji-Labo ではデザイナーとエンジニアが協力して、「手ざわりのいいUI」の実現に向けて、日々励んでいます。
デザイン通りに実装するだけではなく、フロントエンドエンジニアとして、UI の裏側で動作するロジックを通じてプロダクト価値を高めるUI実装ができるよう取り組んでいます。

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

今すぐの転職でなくてもOKです!まずはお話しませんか?

現在弊社では一緒にお仕事をしてくださるエンジニアさんやデザイナーさんを積極募集しています。まずはカジュアルな面談で、お互いに大事にしていることをお話できたらうれしいです。詳しい応募要項は以下からチェックしてください。

パートナー契約へのお問い合わせもお仕事へのお問い合わせも、どちらもいつでも大歓迎です。まずはオンラインでの面談でお話しましょう。ぜひお気軽にお問い合わせください!

話をしてみたい!

投稿者 Tsuji Atsuhiro

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