JavaScript/TypeScriptメモ

TypeScript Promiseをfp-tsのTask、TaskEitherに変換する

Promiseをfp-tsのTaskEitherで表現する

JavaScript(TypeScript)のPromiseは、複数のPromiseを処理したい場合、書き方が少しわかりにくく感じます。

fp-tsのTaskEitherを使うことで、そのような処理をすっきりとわかりやすく書けるようになります。

そこで、最初に、fp-tsのTaskEitherの基本を解説していきます。

TaskEitherとは(概要)

TaskEitherというのは、fp-tsで準備されている型で、TaskとEitherを組み合わせてできたものです。

Promiseオブジェクトをfp-tsで扱うときには、ほとんどの場合にこの型を使うことになります。

TypeScriptの型定義で書くと、TaskEither<E, A>>、あるいは、Task<Either<E, A>> という型を持ちます。

この型の意味を理解するために、Task型、Either型について、簡単に説明をしていきます。

Taskとは?

Taskというのは、絶対に例外を返さないPromiseオブジェクトを表す型(interface)です。

定義は次のとおりです。

// fp-ts/lib/Task.d.ts

export interface Task<A> {
  (): Promise<A>
}

このように、**Taskとは「引数のない」「返り値がPromise」である「function」**のことをいいます。

以下、Taskのことを「Task」「Task型」「Taskオブジェクト」などと表記していきます。

Taskの生成

Taskの値を作るには、特殊な生成関数を使う必要はありません。

次のように、Promiseオブジェクトを関数でラップすればTaskオブジェクトを作ることができます。

import * as T from 'fp-ts/Task';

//Task型の値を生成する例1
//T.Task<number>型のオブジェクトが得られる
const task1 = () => Promise.resolve(10);

//Task型の値を生成する例2
//T.Task<number>型のオブジェクトが得られる
const task2 = () => {
  const p = new Promise<number>((resolve) => {
    //...
    //いろいろな処理 
    //...
    resolve(10);
  });
  return p;
};

なお、上記例の1行目にあるimport文は、このソースコードを実行するだけなら不要です。 T.Task< ... >型が、'fp-ts/Task'内で定義されていることを示すために敢えて入れています

Taskの実行結果を取り出す

Taskの実態は、Promiseオブジェクトを返り値とする関数です。

ですから、Taskの実行結果は、次のようにすると取り出すことができます。

  1. Taskオブジェクトに「()」をつけてfunctionを実行。Promiseオブジェクトを取得する
  2. awaitなど、Promiseオブジェクトの実行結果を取り出す
//Taskを実行する例1
const test1 = async () => {
  const task1 = () => Promise.resolve(10);

  //1. Promiseオブジェクトを取得  const promise1 = task1();
  //2. awaitして、実行結果「10」を取得  const ret1 = await promise1;};

//Taskを実行する例1(別の書き方)
const test1_2 = async () => {
  const task1 = () => Promise.resolve(10);

  //一気にPromiseの返り値「10」を取り出すこともできる  const ret1 = await task1();};

//Taskを実行する例2
const test2 = async () => {
  const task2 = () => {
    const p = new Promise<number>((resolve) => {
      //...
      //いろいろな処理
      //...
      resolve(10);
    });
    return p;
  };

  //一気にPromiseの返り値「10」を取り出す  const ret2 = await task2();};

TaskオブジェクトとPromiseオブジェクトの違い

Taskオブジェクトは、Promiseオブジェクトを関数でラップしたものです。

ですから、Taskオブジェクトを生成した時点では、Promiseオブジェクト内部の処理は実行されないことに注意しましょう。

const taskTest = async () => {
  console.log(`1.Task生成前`);  const task = () => {
    const p = new Promise<number>((resolve) => {
      console.log(`3.Promise内部`);      resolve(10);
    });
    return p;
  };
  console.log(`2.Task生成後`);
  const promise = task();
  console.log(`4.task実行後`);
  const ret = await promise;
  console.log(`5.返り値 ${ret}`);};

taskTest().catch(() => {
  console.log(`error`);
});

//↓ 実行結果
//1.Task生成前
//2.Task生成後
//3.Promise内部
//4.task実行後
//5.返り値 10

上の例を見ると、Promiseオブジェクト内部の処理が実行されるのは、task()を実行した瞬間であることがわかります。

Taskの問題点

冒頭で書いたとおり、Taskオブジェクトは、絶対に例外を返さないPromiseオブジェクトだけを扱うことができます。

とはいえ、現実には、このようなPromiseオブジェクトを扱うことはないので、ほとんど使い道がありません。

そこで、実際に使うときには、(次の項で説明する)Either型を返すTaskオブジェクトを作ることで、例外を返すPromiseオブジェクトも扱えるようにします。

Eitherとは?

Eitherとは、指定した2つの型の「どちらかの型の値」を持つ型です。

Either型は、次のようにLeft型とRight型のUnionとして定義されています。

// fp-ts/lib/Either.d.ts

export interface Left<E> {
  readonly _tag: 'Left'
  readonly left: E
}

export interface Right<A> {
  readonly _tag: 'Right'
  readonly right: A
}

export declare type Either<E, A> = Left<E> | Right<A>

Eitherを使うメリット、Either型の使い道

Either型を使えば、正常終了時はRight型、異常終了時はLeft型を返すことで、正常終了か異常終了かを返り値で判別できます

Either型を使わない場合、関数で異常終了したことを示そうとする場合、次のような手段が一般的です。

  • null、undefinedを返す
  • 例外を返す

それに比べ、Either型を使うことで、次のようなメリットがあります。

エラーの内容を返せる

null,undefinedを使うと、エラーの内容を返すことができません。

一方で、Either型を使えば、エラーの内容を返り値として返すことができます。

呼び出し側としては、エラーの内容がわかるのは大きなメリットです。

エラー時の型を明示できる

例外を返す場合、エラー時の型を明示できません。

一方で、Either型を使えば、エラー発生時の返り値の型を明示することができます。

ですから、エラー処理が書きやすくなります。

エラー判定を強制できる

例外を返す場合、エラー判定を強制することができません。 実際、例外を返す関数を呼び出した場合、その関数の周りにtry~catch文を入れなくても構文エラーにはなりません。

一方で、Either型の場合、正常値を取り出すために、if文を使うなど異常値でないかどうかを切り分ける処理を書く必要があります

その結果、異常時のロジックを書き洩らすリスクを減らすことができます。

Either型の使用例

実際の使用例を見てみましょう。

下記の例では、文字列を数値に変換するconvertToNumber関数を作っています。

import * as E from 'fp-ts/Either';

const convertToNumber = (numberString: string): E.Either<string, number> => {
  const num = Number(numberString);

  //変換に失敗した場合には、エラーの内容をLeft<string>型で返す
  if (isNaN(num)) return E.left(`${numberString}を数値に変換できませんした`);

  //変換に成功した場合には、変換結果をRight<number>型で返す
  return E.right(num);
};

const ret1 = convertToNumber(`123`);

// 変換が正しく行われるので、結果はRight<number>型
// { _tag: 'Right', right: 123 }
console.log(ret1);

if (E.isRight(ret1)) {
  console.log(`123 の変換結果: ${ret1.right}`);
} else {
  console.log(`123 の変換失敗: ${ret1.left}`);
}

const ret2 = convertToNumber(`abc`);

// 変換に失敗したので、結果はLeft<string>型
// { _tag: 'Left', left: 'abcを数値に変換できませんした' }
console.log(ret2);

if (E.isRight(ret2)) {
  console.log(`abc の変換結果: ${ret2.right}`);
} else {
  console.log(`abc の変換失敗: ${ret2.left}`);
}

//↓実行結果
// { _tag: 'Right', right: 123 }
// 123 の変換結果: 123
// { _tag: 'Left', left: 'abcを数値に変換できませんした' }
// abc の変換失敗: abcを数値に変換できませんした
convertToNumber関数の処理

convertToNumber関数では、返り値をEither<string,number>型にして、

  • 数値への変換に「失敗」した場合には「エラーメッセージ」
  • 数値への変換に「成功」した場合には「変換結果」

を返しています。

もう少し、細かく説明すると、E.right関数やE.left関数を使って

  • 数値への変換に「失敗」した場合には、エラーメッセージをLeft<string>型で
  • 数値への変換に「成功」した場合には、変換結果をRight<number>型で

返しています。

convertToNumber関数を抜粋
const convertToNumber = (numberString: string): E.Either<string, number> => {
  const num = Number(numberString);

  // 変換に「失敗」した場合には、エラーの内容をLeft<String>型で返す  if (isNaN(num)) return E.left(`${numberString}を数値に変換できませんした`);
  //変換に「成功」した場合には、変換結果をRight<number>型で返す  return E.right(num);};

このように、異常終了時の値はLeft型、正常終了時の値はRight型で返すことが一般的です。

ちなみに、Left型、Right型のLeft、Rightに深い意味はありません。「一方ともう片方」程度の意味しかありませんので、Left、Rightの語感は忘れてしまったほうがいいかもしれません。

ただ、Left型、Right型のどちらが正常値かわからなくなったときには、Rightには「正しい」という意味があることを手掛かりにして、「正常値はRight型」と覚えるといいかもしれません。

ひょっとしたら「正常=Right」という発想が最初にあって、その後、Rightの対といえばLeftだろうということでLeft、Rightという呼称になったのかもしれません(が、定かではありません)。

Either型の返り値を受け取ったときの処理

Either型を受け取った側では、E.isRight関数を使って、返り値がRight型かどうかを判定しています。

  • Right型の場合には、rightプロパティに返り値の実態が格納
  • Left型の場合には、leftプロパティにエラーメッセージの実態が格納

されているので、それらを取り出していきます。

先ほどの例の13行目~23行目までは、convertToNumber関数で、正常に数値変換ができたパターンです。

正常に数値に変換できたので、変数ret1には、{ _tag: 'Right', right: 123 }というRight型のオブジェクトが格納されています。

そこで、まず、E.isRight関数を使って型の絞り込みを行いましょう。

その後、if文内でrightプロパティを参照すれば、変換結果を取得することができます。

正常時(right)の処理
const ret1 = convertToNumber(`123`);

// 変換が正しく行われるので、結果はRight<number>型// { _tag: 'Right', right: 123 }console.log(ret1);

if (E.isRight(ret1)) {
  console.log(`123 の変換結果: ${ret1.right}`);} else {
  console.log(`123 の変換失敗: ${ret1.left}`);
}

先ほどの例の25行目~35行目は異常パターンです。

数値変換ができなかったので、変数ret2には、{ _tag: 'Left', left: 'abcを数値に変換できませんした' }というLeft型のオブジェクトが格納されています。

処理自体は、先ほどと同じです。

E.isRight関数を使って、型の絞り込みを行ったうえでelse節内でleftプロパティを参照しましょう。

これで、エラーメッセージを取得することができます。

異常時(left)の処理
const ret2 = convertToNumber(`abc`);

// 変換に失敗したので、結果はLeft<string>型// { _tag: 'Left', left: 'abcを数値に変換できませんした' }console.log(ret2);

if (E.isRight(ret2)) {
  console.log(`abc の変換結果: ${ret2.right}`);
} else {
  console.log(`abc の変換失敗: ${ret2.left}`);}

fp-tsには、leftプロパティ・rightプロパティを直接操作しないでも済むような便利な関数も準備されています。

今回の目的とは関係が薄いので説明は省略しますが、興味があったら調べてみてください。

TaskとEitherを組み合わせて、すべてのPromiseをTask(TaskEither)に変換できるようにする

冒頭で書いたとおり、Taskオブジェクトは、絶対に例外を返さないPromiseオブジェクトだけを扱うことができます。

このままでは、例外が発生するPromiseオブジェクトを扱うことができません。

そこで、次のようにしてPromiseオブジェクトを変換していきます。

  1. Promiseオブジェクトを、Taskオブジェクト(※例外が発生する可能性がある)に変換する
  2. Taskオブジェクトを、返り値がEitherのTaskオブジェクト(例外は発生しない)に変換する

「2.」のステップでは、Taskオブジェクトの最終的な返り値をEither型にして、正常終了したときにはRight型、例外が発生したときにはLeft型で値を返すようにします。

このTaskとEitherを組み合わせた型のことをTaskEither型と呼びます。

こうすることで、例外が発生するPromiseを、Task型(TaskEither型)として扱えるようになります。

Promiseで例外(reject)が発生する例

次のconvertToNumberPromise関数を見てください。

Promiseで例外(reject)が発生する例
const convertToNumberPromise = (numberString: string): Promise<number> =>  new Promise((resolve, reject) => {    const num = Number(numberString);    //変換に失敗した場合にはreject    if (isNaN(num)) reject(`${numberString}を数値に変換できませんした`);    //変換に成功した場合には変換結果をresolve    return resolve(num);  });
const main = async () => {
  //正常終了
  const ret1 = await convertToNumberPromise('123');
  console.log(ret1);

  //例外発生
  const ret2 = await convertToNumberPromise('abc');
  console.log(ret2);
};

main().catch((e) => {
  console.log(e as string);
});

//↓実行結果
// 123
// abcを数値に変換できませんした

convertToNumberPromise関数は、先ほどのconvertToNumber関数と、処理はほぼ同じです。

ただ、返り値の返し方が違っています。

返り値はPromise<number>で、正常終了時にはnumber型の値をresolveし、異常時にはstring型の値をrejectしています。

例外が発生するPromiseをTaskに仮変換する

このconvertToNumberPromise関数を、まずはTaskに変換します。

PromiseをTaskに変換
import * as T from 'fp-ts/Task';

const convertToNumberPromise = (numberString: string): Promise<number> =>
  new Promise((resolve, reject) => {
    const num = Number(numberString);

    //変換に失敗した場合にはreject
    if (isNaN(num)) reject(`${numberString}を数値に変換できませんした`);

    //変換に成功した場合には変換結果をresolve
    return resolve(num);
  });

//convertToNumberPromise関数をTaskに変換するconst convertToNumberTask =  (numberString: string): T.Task<number> =>  () =>    convertToNumberPromise(numberString);
const main = async () => {
  //正常終了
  const ret1 = await convertToNumberTask('123')();  console.log(ret1);

  //例外発生
  const ret2 = await convertToNumberTask('abc')();  console.log(ret2);
};

main().catch((e) => {
  console.log(e);
});

//↓実行結果
// 123
// abcを数値に変換できませんした

新たにconvertToNumberTask関数(14~18行目)を作り、main関数内でconvertToNumberTask関数を呼び出す(22行目、26行目)ように書き換えました。

convertToNumberTask関数は、string型の引数を受け取ると、T.Task<number>型のオブジェクト() => convertToNumberPromise(numberString)を返します。

なお、22行目、26行目でTask型のオブジェクトを使うときには、末尾に「()」を付けることを忘れないようにしましょう。

Taskの返り値をEitherに変換してTaskEitherにする

さらに、Taskオブジェクトの返り値をEither型に変換して、TaskEitherオブジェクトを作ります。

TaskをTaskEitherに変換
import * as T from 'fp-ts/Task';
import * as E from 'fp-ts/Either';import * as TE from 'fp-ts/TaskEither';
const convertToNumberPromise = (numberString: string): Promise<number> =>
  new Promise((resolve, reject) => {
    const num = Number(numberString);

    //変換に失敗した場合にはreject
    if (isNaN(num)) reject(`${numberString}を数値に変換できませんした`);

    //変換に成功した場合には変換結果をresolve
    return resolve(num);
  });

const convertToNumberTask =
  (numberString: string): T.Task<number> =>
  () =>
    convertToNumberPromise(numberString);

//Taskオブジェクト(ConvertToNumberTask)をTaskEitherオブジェクトに変換するconst convertToNumberTaskEither = (  numberString: string): TE.TaskEither<string, number> =>  TE.tryCatch(convertToNumberTask(numberString), (r) => r as string);
const main = async () => {
  const ret1 = await convertToNumberTaskEither('123')();

  // 変換が正しく行われるので、結果はRight<number>型
  // { _tag: 'Right', right: 123 }
  console.log(ret1);

  if (E.isRight(ret1)) {
    console.log(`123 の変換結果: ${ret1.right}`);
  } else {
    console.log(`123 の変換失敗: ${ret1.left}`);
  }

  const ret2 = await convertToNumberTaskEither('abc')();

  // 変換に失敗したので、結果はLeft<string>型
  // { _tag: 'Left', left: 'abcを数値に変換できませんした' }
  console.log(ret2);

  if (E.isRight(ret2)) {
    console.log(`abc の変換結果: ${ret2.right}`);
  } else {
    console.log(`abc の変換失敗: ${ret2.left}`);
  }
};

main().catch((e) => {
  //Promiseで例外が発生しないので、このロジックは通らない  console.log(e);
});

//↓実行結果
// { _tag: 'Right', right: 123 }
// 123 の変換結果: 123
// { _tag: 'Left', left: 'abcを数値に変換できませんした' }
// abc の変換失敗: abcを数値に変換できませんした

21行目~25行目で、convertToNumberTask関数を、TaskEither型に変換しています。

convertToNumberTaskEither関数を抜粋
const convertToNumberTaskEither = (
  numberString: string
): TE.TaskEither<string, number> =>
  TE.tryCatch(convertToNumberTask(numberString), (r) => r as string);

TaskEither型への変換には、TaskEitherモジュールのtryCatch関数を使っています。

tryCatch関数の1つ目の引数にTask型オブジェクト、2つ目の引数に例外をcatchしたときの処理ロジック(≒Promise.catch節の中の処理ロジック)をを渡すと、TaskEither型のオブジェクトが得られます。

ロジック的には、次のものとまったく同じです。

convertToNumberTaskEitherを別ロジックで書いたもの
const convertToNumberTaskEither =
  (numberString: string): TE.TaskEither<string, number> =>
  () =>
    convertToNumberTask(numberString)()
      .then((v) => E.right(v))
      .catch((r) => E.left(r as string));

このようにして、TaskEitherオブジェクトを作って、TaskEitherの処理結果をawaitすれば、後続処理は、通常のEitherに対する処理とまったく同じです。

await語は、通常のEitherに対する処理とまったく同じ
  const ret1 = await convertToNumberTaskEither('123')();

  // 変換が正しく行われるので、結果はRight<number>型
  // { _tag: 'Right', right: 123 }
  console.log(ret1);

  if (E.isRight(ret1)) {
    console.log(`123 の変換結果: ${ret1.right}`);
  } else {
    console.log(`123 の変換失敗: ${ret1.left}`);
  }

これで、すべてのPromiseをTaskEitherに置き換えることができるようになりました。

TaskEitherを使うと、何が便利なのかは、次の記事で解説します。