neverthrow と fetch と zod を組み合わせた非同期処理


こんにちは。フロントエンドエンジニアの辻です。

Gaji-Labo ではデザイナーとエンジニアが協力して、「手ざわりのいいUI」の実現に向けて、日々励んでいます。
フロントエンドエンジニアとしては、デザイン通りに実装するのはもちろんの事、UI の裏側で動作するロジックにも同じように注力しています。今回はその裏側のロジックについての話です。

はじめに、フロントエンドにおけるロジック実装の一手として neverthrow というライブラリを紹介します。
次に neverthrow と fetch と zod を組み合わせた非同期処理を書いていきます。

neverthrow とは?

neverthrow とは、JavaScript(TypeScript)においてエラーをスローせずに Result 型として扱うライブラリです。その名の通りですね。
neverthrow を導入すると、JavaScript(TypeScript)の try ~ catch 構文を、Rust 言語における Result 型に似た機能で代用できるため、より安全で堅牢なコードを記述できます。

> neverthrow 公式ページ
> Result – Rust By Example

以下は neverthrow のサンプルコードです。

import { Result, ok, err } from "neverthrow";

/**
 * 引数の target を numbers 配列のいずれかの値で除算する関数です。
 * もし 0 で除算しようとした場合は err() を返します。
 * 0 以外で除算する場合は ok() を返します。
 */
const divide = (target: number): Result<number, string> => {
  const numbers = [0, 1, 2];
  const index = Math.floor(Math.random() * numbers.length);

  if (numbers[index] === 0) return err("0で割ることはできません。");
  return ok(target / numbers[index]);
};

/**
 * isOk() は処理が成功したか(okを返したか)を判定します。
 */
const result = divide(10);
if (result.isOk()) {
  console.log(result.value); // 10 or 5
} else {
  console.log(result.error); // 0で割ることはできません。
}

neverthrow と非同期処理を組み合わせてみる

とても便利な neverthrow ですが、私は非同期処理と組み合わせて使っています。
非同期処理はそれ自体の難易度もさることながら、エラーハンドリングも考慮すると一筋縄ではいきません。
そこで neverthrow の出番です。

さっそくサンプルコードを用意しました。

import { Result, ok, err } from "neverthrow";

/**
 * neverthrow と fetch を組み合わせた関数です。
 * ジェネリクスの R は API のレスポンスの型。P はリクエストパラメータの型です。
 */
export const handleFetch = <R, P = unknown>({
  path,
  method = "GET",
  requestParams,
}: {
  path: string;
  method?: "GET" | "POST" | "PUT" | "DELETE";
  requestParams?: P;
}): Promise<Result<R, Error>> => {
  // リクエストパラメータの整備
  const params: RequestInit = {
    method,
  };
  if ((method === "POST" || method === "PUT") && requestParams) {
    params.body = JSON.stringify(requestParams);
  }

  // fetch の実行
  return fetch(path, params)
    .then((res) => {
      if (!res.ok) {
        // 通信に失敗した場合は例外を投げます
        // 最終的に、例外は catch 句にて err() でラップして返します
        throw new Error("サーバーとの通信に失敗しました");
      }

      return res.json();
    })
    .then((data: R) => {
      return ok(data);
    })
    .catch((error: Error) => {
      return err(error);
    });
};

今回用意した handleFetch 関数ですが、リクエストパラメータの整備や fetch の実行については、一般的な非同期処理と変わりありません。
neverthrow を導入した事による変化として、戻り値が ok() か err() でラップされている点が挙げられます。
これにより、handleFetch 関数の戻り値の型は Promise<Result<R, Error>> となり、handleFetch 関数の呼び出し元では isOk() や isErr() が扱えます。

実際に使ってみるとこんな感じです。

import { handleFetch } from "./neverthrow_fetch";

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

const run = async () => {
  const result = await handleFetch<Todo>({
    path: "https://jsonplaceholder.typicode.com/todos/1",
  });

  if (result.isOk()) {
    console.log(result.value); // { "userId": 1, "id": 1, "title": "title text", "completed": false }
  } else {
    console.log(result.error);
  }
};

run();

ちなみに、neverthrow は非同期の Result 型を表現する機能として ResultAsync を用意しています。
今回は分かりやすさを優先して Promise<Result<T, E>> としていますが、開発時は ResultAsync<T, E>とした方がより効率的でしょう。

> Asynchronous API (ResultAsync) | neverthrow

zod と組み合わせてみる

最後に handleFetch 関数と zod を組み合わせて使ってみます。
zod はスキーマのバリデーションライブラリです。シンプルな使い勝手ながらも機能は強力です。

先程の handleFetch 関数と zod を組み合わせたサンプルコードです。

import { err, ok, fromPromise } 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) => {
      // URL 末尾に入る id を zod で検証して、その結果を ok() か err() に振り分ける … (1)
      if (!id.success) return err(id.error);
      return ok(id.data);
    })
    .asyncAndThen((id) => {
      // 検証後の id を利用して API を実行する … (2)
      // fromPromise は Promise<Result<Todo, Error>> を ResultAsync として扱うため
      return fromPromise(
        handleFetch<Todo>({
          path: `https://jsonplaceholder.typicode.com/todos/${id}`,
        }),
        (error) => error
      );
    })
    .map((result) => {
      // 一連の処理が成功した場合 … (3)
      if (result.isOk()) {
        console.log(result.value);
      }
    })
    .mapErr((error) => {
      // エラーが発生した場合 … (4)
      if (error instanceof ZodError) {
        console.log("zod エラー", error);
      } else {
        console.log("API エラー", error);
      }
    });
};

run(1);

zod の safeParse 関数の結果を、ok() に入れる所からはじめて、

  1. URL 末尾に入る id を zod で検証して、その結果を ok()err() に振り分ける
  2. zod の検証が成功後に、その結果を利用して API を実行する
  3. 一連の処理が成功した場合、その結果を出力する
  4. エラーが発生した場合、 instanceof 構文でエラーの種別を判定して、エラーハンドリングを行う

…の流れになっています。

今回利用した neverthrow の andThen、asyncAndThen、map、mapErr の機能は、それぞれ次の通りです。

andThen と asyncAndThen

andThen 関数は、Result 型オブジェクトに対して連続した操作をするために利用します。イメージとしては非同期処理における then が近いですね。
andThen 関数は、Result 型オブジェクトが Ok の場合にのみ、引数の関数を実行します。もし Result 型オブジェクトが Err であれば引数の関数は実行されず、そのまま Err を返します。

先程のサンプルコードでは、zod で検証した結果を ok()err() に振り分けています。

また、andThen 関数が Result 型オブジェクトを扱うのに対して、asyncAndThen 関数は ResultAsync 型オブジェクトを扱います。
handleFetch 関数の実行時に asyncAndThen 関数を利用しているのは、そのためです。

> Result.andThen | neverthrow

> Result.asyncAndThen | neverthrow

map

map 関数も andThen 関数と似たような動きをします。
map 関数も Result 型オブジェクトが Ok の場合に、引数の関数を実行します。もし Result 型オブジェクトが Err であれば引数の関数は実行されず、そのまま Err を返します。
andThen 関数がより複雑な処理に向いているのに対して、map 関数は Ok の値を変換する処理に向いています。(今回はただログを出力しているだけですが…)

> Result.map | neverthrow

andThen も asyncAndThen も map も、Result 型オブジェクトが Ok の場合のみ、引数の関数を発火させます。途中で処理が失敗して Err になっていた場合は、引数に指定した関数は発火しません。

先程のサンプルコードでは、run 関数の引数に 1 〜 100 以外の数値を入れると Zod エラーが発生するため、後続の handleFetch 関数が発火しないようになっています。

mapErr

map 関数と対となるのが mapErr 関数です。
mapErr 関数は Result 型オブジェクトが Err の場合に引数の関数を実行します。

先程のサンプルコードでは、Zod エラーと API エラーの 2 つをまとめて mapErr 関数で処理しています。

> Result.mapErr | neverthrow

まとめ

簡単なサンプルではありますが、neverthrow と fetch の組み合わせから、zod との併用までを見てきました。
非同期処理に neverthrow を導入すれば、パラメータ検証 → API 実行 → レスポンスの加工 + エラーハンドリングを一連の処理として記述できます。

非同期処理で try ~ catch 構文を利用すると、どうしても複雑なコードになってしまいますし、エラーが発生した場合に処理フローを追いかけるだけでも一苦労です。
そんな時は、ぜひ neverthrow を検討してみてはいかがでしょうか?

続編となる記事「neverthrow と fetch と zod を組み合わせた非同期処理【直列実行と並列実行編】」を執筆しました。良ければコチラもご覧ください。


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

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

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

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

話をしてみたい!

投稿者 Tsuji Atsuhiro

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