🥦

NeverThrow入門 | TypeScriptでResult型を使いたいんじゃ^〜

2024/07/19に公開

会社でNeverThrowというライブラリを使っています。

とても便利なので、とても便利だよ〜という記事を書きます。

NeverThrowとは?

NeverThrowは、TypeScriptで「Result型」を実現できるライブラリです。

Result型とは?

Result型は、関数の中でエラーをthrowする代わりに、エラーを戻り値として返すようにすればいいじゃね?な仕組みのことをいいます。

もっと噛み砕いて説明します。

たとえば「50%の確率で足し算してくれるけど、50%の確率で💩をthrowする」という関数があるとします。

コードで表すと👇になります。

function add(a: number, b: number) {
  if (Math.random() >= 0.5) {
    throw new Error('💩');
  }

  return a + b;
}

意味不明な関数ですが、説明しやすいのでこれを使います!

さて、この関数を使って足し算をしなければいけないとすると、呼び出す側はtry-catchで囲む必要があります👇

try {
  const result = add(1, 1); // 呼び出し
  console.log("足し算の結果: ", result);
} catch (error) {
  console.error("足し算に失敗しました");
  console.log(error.message) // 💩
}

なぜなら50%の確率で💩をthrowするからです(当たり前)。

try-catchで囲まなくても呼び出すことができますが、その場合💩をキャッチできないので、アプリが止まってしまうかもしれません。

うーん・・
なんかこの関数、使う側からすると微妙ですよね。

というのも👇のルールを守るのが前提になっているからです。

  • 事前に「add関数はエラーをthrowするかもしれない」を知っておく必要があり、
  • 使うときはtry-catchで囲む必要がある(囲み忘れると死ぬ)。

はい!この問題を解決できるのがNeverThrowになります!

NeverThrowでResult型を実現する

たとえば先ほどの関数を、NeverThrowを使ってResult型を使うように書き換えてみます👇

import { err, ok } from 'neverthrow';
import type { Result } from 'neverthrow';

function add(a: number, b: number): Result<number, Error> {
  if (Math.random() >= 0.5) {
    return err(new Error('💩')); // 👈 err()でエラーをラップ
  }

  return ok(a + b);// 👈 ok()で返す値をラップ
}

書き換えた結果、add関数はtry-catch不要で書けるようになります👇

const result = add(1, 1)

if (result.isOk()) {
  console.log(result.value);
  return;
}

console.log("足し算に失敗しました")
console.log(result.error.message) //💩

returnせずに、isErr()を使っても書けます👇

const result = add(1, 1)

if (result.isOk()) {
  console.log(result.value);
}

if (result.isErr()) {
  console.log("足し算に失敗しました")
  console.log(result.error.message) //💩
}

おお〜〜〜、分かりやすくなった!!(気がする)

それでいて先ほどの問題も解決されてそう。

NeverThrowの使い方

こんな書き方ができるよ〜を色々書きます。

メソッドチェーンする

andThen()🔵を使うと、メソッドチェーンで繋げられます👇

// 1 + 1 + 2 + 2 = 6
const result = add(1, 1)
  .andThen((res1) => add(res1, 2)) // 🔵
  .andThen((res2) => add(res2, 2)) // 🔵

if (result.isOk()) {
  console.log(result.value); // 6
  return;
}

エラーハンドリングする

いろいろな方法があります。

mapErr

isErr()で処理せずに、メソッドチェーンにmapErr()🔴を繋げて処理することもできます👇

const result = add(1, 1) // 🔵
  .andThen((res) => add(res, 2)) // 🔵
  .mapErr(e => { // 🔴
    console.log("足し算でエラーが起こりました");
  });

この場合、🔵で発生したエラーは、🔴でキャッチされます。
エラーが発生しなかった場合、🔴は実行されません。

unwrapOr

unwrapOr()を使うと「失敗したらこの値を使ってね」ができます。

たとえば👇のように「足し算に失敗したら10を返してね」ができます。

const result = add(1, 1).unwrapOr(10)

console.log(result); // 2 or 10

orElse

orElse()🔵を使うと「この失敗パターンの場合、◯の処理をした後に成功として扱ってね」ができます。

たとえば👇のように、エラー内容に応じてok()err()を出し分けできます。

const result = なんかリスクのある処理()
  .orElse((e) => {                // 🔵
    if(e.name === "軽微なエラー"){
      console.log("放置でOK!!");
      return ok(10);
    }
    if(e.name === "ヤバいエラー"){
      console.error("ぐわああああああああああああ!!");
      return err(10);
    }
    console.log('知らないエラーだけど大丈夫でしょ!!')
    return ok(10);
  })

複数エラーのハンドリング

「引き算」の関数も用意して、それぞれ繋げて書いてみます👇

class AddError extends Error {
  constructor(message: string) {
    super(message);
    this.name = 'AddError' as const;
  }
}

class SubtractError extends Error {
  constructor(message: string) {
    super(message);
    this.name = 'SubtractError';
  }
}

function add(a: number, b: number): Result<number, AddError> {
  if (Math.random() >= 0.5) {
    return err(new AddError('足し算で失敗しました💩'));
  }

  return ok(a + b);
}

function subtract(a: number, b: number): Result<number, SubtractError> {
  if (Math.random() >= 0.5) {
    return err(new SubtractError('引き算で失敗しました💩'));
  }

  return ok(a - b);
}

const result = add(1, 1)
  .andThen((res) => subtract(res, 2))
  .mapErr(e => {
    if (e instanceof AddError) { //🔴もっと良い感じに書けそう?
      console.log("足し算でエラーが起こりました");
    }
    if (e instanceof SubtractError) {
      console.log("引き算でエラーが起こりました");
    }
    // 🔵この時点でeはnever型になる
  })

🔴の部分は、AddErrorSubtractErrorにプロパティを追加して、タグ付きユニオンで絞り込めばswitch文で良い感じに書けるかもです。

🔵の部分は、この時点でenever型に絞り込まれるべきなので「never型にならないとエラーを出す」という処理を加えると良いかもです(こちらのサイトで書かれているような書き方をすると実現できます)。

また、これらをシンプルに実現する方法として、switch文ではなくts-patternというライブラリを使っても良いかもです。

元コードを変更せずにResult型にする

add関数は、自作の関数だったので変更できましたが、外部ライブラリとなると変更できません。

そういうときはfromThrowable()🔵で丸ごとラップしてあげると、Result型を返すように変換できます👇

// 最初のadd関数のまま
function add(a: number, b: number) {
  if (Math.random() >= 0.5) {
    throw (new Error('足し算💩'));
  }

  return (a + b);
}

// Result型を返す関数に変換
const safeAdd = Result.fromThrowable(add); // 🔵

const result = safeAdd(1, 1)
  .andThen((res) => safeAdd(res, 2))
  .mapErr((e) => { // 🔴ここはunknownになる
    console.log("なにかエラーが起きました");
  })

if(result.isOk()){
  console.log(result.value); // 4
}

ただし、fromThrowable()でラップしても「よくわからんけど、この関数はなにかしらのエラーを投げるらしい」という情報しか分からないので、🔴行のeの型はunknownになっちゃうので注意です。

それだと困る場合はfromThrowableの第2引数にコールバック🟢を渡すと、指定したエラーが返るようにできます👇(これは公式の例)。

import { Result } from 'neverthrow'

const toParseError = () => ({ message: "パースできませんでした" })

const safeJsonParse = Result.fromThrowable(
  JSON.parse,
  toParseError, // 🟢
)

const res = safeJsonParse("{"); //Errが返る

無理やり値を取り出す

「add関数は絶対に成功している!!」という確信があるときは、_unsafeUnwrap()🔵で無理やり値を取り出せます👇

function add(a: number, b: number) {
  if (Math.random() >= 0.5) {
    throw (new Error('足し算💩'));
  }

  return (a + b);
}

const safeAdd = Result.fromThrowable(add)

const result = safeAdd(1, 1)
  .andThen((res) => safeAdd(res, 2))
  .mapErr((e) => {
    console.log("なにかエラーが置きました");
  })

console.log(result._unsafeUnwrap()); // 🔵

名前のとおり_unsafeと書いているので基本使うべきではなくて、テストのときに使うと良いらしいです。

Result型を使わない処理を途中で行う

map()🔵を使います。

JavaScriptにもmap()がありますが、NeverThrowのmap()です。

たとえば途中で、計算結果を途中で出力したい場合は、👇のように書けます。

const result = await add(1, 1)
  .map((res) => { // 🔵
    console.log(res); // 2
    return res;
  })
  .andThen((res) => add(res, 2))

if (result.isOk()) {
  console.log(result.value); // 4
}

andThenとの違いは👇です。

  • andThen: 「Result型を返す関数」を引数にとる
  • map:「関数」を引数にとる

どちらで書いた場合も、returnした値が次のチェーンの引数に渡されます。

一気に取り出す

combine()🔵を使います👇

const result = Result.combine([ // 🔵
  add(1, 1),
  add(2, 2),
]).mapErr((e) => {
  console.error(e.message); // 足し算💩
});

if (result.isOk()) { // どちらも成功した場合のみtrueになる
  console.log(result.value[0]); // 2
  console.log(result.value[1]); // 4
}

combine()だと、1つエラーが出た時点でmapErrorに処理が移りますが
combineWithAllErrors()🟢だと全ての処理が終わるまで待てます👇

const result = Result.combineWithAllErrors([ // 🟢
  add(1, 1),
  add(2, 2),
]).mapErr((errors) => {
    // 2つとも失敗した場合、「足し算💩」が2回出力される
  errors.forEach((e) => console.error(e.message));

});

if (result.isOk()) { // どちらも成功した場合のみtrueになる
  console.log(result.value[0]); // 2
  console.log(result.value[1]); // 4
}

非同期処理をする

ここまでは全て同期処理の話でした。

ここから非同期処理について書きます。

そんなわけで、add関数を非同期化してみます👇

// 指定したミリ秒だけ処理を止める
function sleep(msec: number) {
  return new Promise(resolve => setTimeout(resolve, msec));
}

// 1秒待機したのち、50%の確率で足し算してくれるけど50%の確率で💩をthrowする
async function addAsync(a: number, b: number) {
  await sleep(1000);
  if (Math.random() >= 0.5) {
     throw new Error('💩');
  }

  return a + b;
}

さらに意味不明な関数になりましたが、説明のためです!

さて、このaddAsync関数をNeverthrowを使って書き換えてみます。

1. ResultAsync.fromThrowableを使った書き換え

まず、addAsyncを丸ごとResultAsync.fromThrowable🔵でラップしてしまう方法があります👇

const safeAddAsync = ResultAsync.fromThrowable(addAsync); // 🔵

const result = await safeAddAsync(1,1)

if(result.isOk()) {
  console.log(result.value); // 1秒後に「2」が表示される
}

safeAddAsyncを実行すると、ResultAsync型が帰ってきます。
ResultAsync型はawaitするとResult型を取り出せます。

なので、あとは同期関数と同じようにresult.isOk()でokの値を取り出せます。

2️. NeverThrowでゴリゴリに書き換え

addAsync自体を、ゴリゴリに書き換える方法もあります👇

function addAsync(a: number, b: number) {
  return ResultAsync.fromSafePromise(sleep(1000))
    .andThen(() => {
      if (Math.random() >= 0.5) {
        return errAsync(new Error('足し算💩'));
      }

      return okAsync(a + b);
    })
}

const result = await addAsync(1,1)

if(result.isOk()) {
  console.log(result.value); // 成功した場合、1秒後に「2」が表示される
}

ただ、このコードは見にくいです。

今回の場合はそもそも、addAsyncは非同期化せずにaddのままにして、sleepをチェーンで繋いで実行するほうが良さそうです👇

function add(a: number, b: number): Result<number, Error> {
  if (Math.random() >= 0.5) {
    return err(new Error('足し算💩'));
  }

  return ok(a + b);
}

const result = await ResultAsync
  .fromSafePromise(sleep(1000)) // 👈addから分離してチェーンで実行
  .andThen(() => add(1, 1));

if (result.isOk()) {
  console.log(result.value); // 成功した場合、1秒後に「2」が表示される
}

さらに言えば、sleepPromiseのままのせいで少し複雑になっているので、sleepResultAsync型を返すようにして、👇のように書くとメインの処理が追いやすくなりそうです。

function sleep(msec: number): ResultAsync<void, never> {
  return fromSafePromise(  // 👈
    new Promise<void>((resolve) => setTimeout(resolve, msec))
  );

const result = sleep(1000)
  .andThen(() => add(1, 1));

if (result.isOk()) {
  console.log(result.value); // 1秒後に「2」が表示される
}

非同期と同期を交互に実行する

非同期と同期を交互に実行させることもできます👇

// add(同期) → sleep(非同期) → add(同期)
const result = await add(1, 1)
  .asyncAndThen((res) => sleep(1000).andThen(() => add(res, 2)))

if (result.isOk()) {
  console.log(result.value); // 1秒後に「6」が表示される
}

非同期に切り替えるにはasyncAndThenが必要ですが、1度でも非同期になった後はandThenを使えば良い・・という感じです。

  • 同期  → 同期 :andThenを使う
  • 非同期 → 同期 :andThenを使う
  • 非同期 → 非同期:andThenを使う
  • 同期  → 非同期:asyncAndThenを使う

まとめ

Result型は便利ですが、「Result型 TypeScript」とかで検索すると反対意見もいろいろ見つかります。

面白いので調べてみてください!

\ PR /

事業拡大に伴い、エンジニアが足りてなくて困っています!

優しくて強いエンジニアが集まっている会社です!

興味のある方はこちらから!👇

recruit | PrAha Inc.

PrAha

Discussion