Skip to content

パイプラインの構築

Effect パイプラインは、値に対する操作の構成やシーケンスを可能にし、データの変換と操作を簡潔かつモジュール化された形で実現します。

パイプラインがアプリケーションの構造化に適している理由

パイプラインは、アプリケーションを構造化し、データ変換を簡潔かつモジュール化された形で処理するための優れた方法です。以下のような多くの利点を提供します。

  1. 可読性: パイプラインを使用すると、関数を読みやすく、直列に構成することができます。データの流れや適用される操作が明確に見えるため、コードの理解と保守が容易になります。

  2. コードの整理: パイプラインを使用すると、複雑な操作を小さく管理可能な関数に分解することができます。各関数は特定のタスクを実行し、コードはよりモジュール化され、推論しやすくなります。

  3. 再利用性: パイプラインは関数の再利用を促進します。操作を小さな関数に分解することで、異なるパイプラインやコンテキストで再利用でき、コードの再利用性が向上し、重複を減らします。

  4. 型の安全性: タイプシステムを活用することで、パイプラインはコンパイル時にエラーを捕捉します。パイプライン内の関数は明確に定義された入力および出力の型を持ち、データが正しく流れることを保証し、ランタイムエラーを最小限に抑えます。

次に、パイプラインを定義し、いくつかの重要な要素を探っていきましょう。

pipe

pipe関数は、関数を読みやすく直列に構成するためのユーティリティです。一つの関数の出力を次の関数への入力として渡します。これにより、複数の関数を連鎖して複雑な変換を構築できます。

pipeの基本的な構文は次のとおりです。

import { pipe } from "effect"
const result = pipe(input, func1, func2, ..., funcN)

この構文では、inputは初期値であり、func1func2、…、funcNは順次適用される関数です。各関数の結果が次の関数の入力となり、最終的な結果が返されます。

以下にpipeの動作を示す例を示します。

Pipe

pipeに渡される関数は単一の引数を持つ必要があることに注意してください。これは、単一の引数でのみ呼び出されるためです。

pipeの動作をより良く理解するために、以下に例を見てみましょう。

import { pipe } from "effect";
// シンプルな算術演算を定義
const increment = (x: number) => x + 1;
const double = (x: number) => x * 2;
const subtractTen = (x: number) => x - 10;
// `pipe`を使ってこれらの操作を順次適用
const result = pipe(5, increment, double, subtractTen);
console.log(result); // 出力: 2

上記の例では、初期値として5を使っています。increment関数が初期値に1を加え、結果として6になります。次にdouble関数がその値を倍にして12となり、最後にsubtractTen関数が12から10を引いて最終出力2になります。

この結果はsubtractTen(double(increment(5)))と同じですが、pipeを使用すると、操作が左から右への順番で配列されているため、コードの可読性が向上します。入れ子構造にするのではなく、直列に構成されているからです。

関数 vs メソッド

Effect エコシステムでは、ライブラリはしばしばメソッドよりも関数を公開しています。この設計選択には、ツリーシェイキングと拡張性の 2 つの重要な理由があります。

ツリーシェイキング

ツリーシェイキングとは、ビルドシステムがバンドルプロセス中に未使用のコードを除去する能力を指します。関数はツリーシェイキング可能であり、メソッドはそうではありません。

Effect エコシステムで関数が使用されると、実際にアプリケーションでインポートされて使用されている関数のみが最終的なバンドルコードに含まれます。未使用の関数は自動的に削除され、バンドルサイズが小さくなり、性能が向上します。

一方、メソッドはオブジェクトやプロトタイプに関連付けられ、簡単にツリーシェイキングができません。サブセットのメソッドのみを使用しても、オブジェクトやプロトタイプに関連付けられたすべてのメソッドがバンドルに含まれ、不必要なコードの肥大化を招きます。

拡張性

Effect エコシステムで関数を使用するもう一つの重要な利点は、拡張性の容易さです。メソッドの機能を拡張するには、通常オブジェクトのプロトタイプを変更する必要があり、これは複雑でエラーを引き起こす可能性があります。

それに対し、関数を使用すれば、機能を拡張するのがはるかに簡単です。自分の”拡張メソッド”を単なる関数として定義でき、オブジェクトのプロトタイプを変更する必要がありません。これにより、よりクリーンでモジュール化されたコードが促進され、他のライブラリやモジュールとの互換性が向上します。

次に、パイプラインを構築するためにpipe関数と組み合わせて使用できる API の例をいくつか見てみましょう。

map

Effect.map関数は、Effect内の値を変換するために使用されます。関数を受け取り、それをEffect内に含まれる値に適用して、変換された値を持つ新しいEffectを作成します。

Effect.map の使用法

Effect.mapの構文は次のとおりです。

import { pipe, Effect } from "effect";
const mappedEffect = pipe(myEffect, Effect.map(transformation));
// または
const mappedEffect = Effect.map(myEffect, transformation);
// または
const mappedEffect = myEffect.pipe(Effect.map(transformation));

上記のコードでは、transformationは値に適用される関数であり、myEffectは変換されるEffectです。

Effectは不変であることに注意が必要です。つまり、Effect.mapを使用してEffectに適用しても、元のデータ型は変更されません。代わりに、変換された値を持つEffectの新しいコピーが返されます。

少額の手数料を取引に加算するプログラムを考えてみましょう:

import { pipe, Effect } from "effect";
// 取引金額に少額の手数料を加算する関数
const addServiceCharge = (amount: number) => amount + 1;
// データベースから取引金額を取得する非同期タスクのシミュレーション
const fetchTransactionAmount = Effect.promise(() => Promise.resolve(100));
// 取引金額に手数料を適用
const finalAmount = pipe(fetchTransactionAmount, Effect.map(addServiceCharge));
Effect.runPromise(finalAmount).then(console.log); // 出力: 101

as

Effectを定数値にマップし、元の値を置き換えるには、Effect.asを使用します。

import { pipe, Effect } from "effect";
const program = pipe(Effect.succeed(5), Effect.as("新しい値"));
Effect.runPromise(program).then(console.log); // 出力: "新しい値"

flatMap

Effect.flatMap関数は、Effectインスタンスを生成する変換を連鎖させる必要がある場合に使用されます。これは、非同期操作や前のEffectの結果に依存する計算に便利です。

Effect.flatMap の使用法

Effect.flatMap関数を使用すると、新しいEffect値を生成する計算をシーケンスさせ、ネストされたEffect構造を”フラット化”できます。

Effect.flatMapの構文は次のとおりです。

import { pipe, Effect } from "effect";
const flatMappedEffect = pipe(myEffect, Effect.flatMap(transformation));
// または
const flatMappedEffect = Effect.flatMap(myEffect, transformation);
// または
const flatMappedEffect = myEffect.pipe(Effect.flatMap(transformation));

上記のコードでは、transformationは値を受け取ってEffectを返す関数であり、myEffectは変換される最初のEffectです。

Effectは不変であることに注意が必要です。つまり、Effect.flatMapを使用してEffectに適用しても、元のデータ型は変更されません。代わりに、変換された値を持つEffectの新しいコピーが返されます。

import { pipe, Effect } from "effect";
// 取引金額に安全に割引を適用する関数
const applyDiscount = (
total: number,
discountRate: number
): Effect.Effect<number, Error> =>
discountRate === 0
? Effect.fail(new Error("割引率はゼロにできません"))
: Effect.succeed(total - (total * discountRate) / 100);
// データベースから取引金額を取得する非同期タスクのシミュレーション
const fetchTransactionAmount = Effect.promise(() => Promise.resolve(100));
const finalAmount = pipe(
fetchTransactionAmount,
Effect.flatMap((amount) => applyDiscount(amount, 5))
);
Effect.runPromise(finalAmount).then(console.log); // 出力: 95

すべての Effect が考慮されることの確認

Effect.flatMap内のすべての Effect が最終的な計算に寄与することを確認することが重要です。Effect を無視すると、予期しない動作や誤った結果につながる可能性があります:

Effect.flatMap((amount) => {
Effect.sync(() => console.log(`割引を適用する: ${amount}`)); // このEffectは無視されます
return applyDiscount(amount, 5);
});

上記のEffect.syncは無視され、applyDiscount(amount, 5)の結果には影響を与えません。Effect を正しく含めてエラーを避けるためには、Effect.mapEffect.flatMapEffect.andThen、またはEffect.tapなどの関数を使って明示的にチェーンする必要があります。

flatMapに関するさらなる情報

多くの開発者がflatMapを配列と共に使用することを認識しているかもしれませんが、EffectフレームワークではネストされたEffect構造を管理し、解決するために使用されます。 もし目標が Effect 内のネストされた配列をフラット化すること(Effect<Array<Array<A>>>)であれば、次のようにできます。

import { pipe, Effect, Array } from "effect";
const flattened = pipe(
Effect.succeed([
[1, 2],
[3, 4],
]),
Effect.map((nested) => Array.flatten(nested))
);

または、標準のArray.prototype.flat()メソッドを使用して行うこともできます。

andThen

Effect.mapおよびEffect.flatMap関数は、異なるシナリオでEffectを別のEffectに変換します。最初のシナリオでは、変換関数がEffectを返さない場合にEffect.mapが使用され、2 番目のシナリオでは、変換関数がまだEffectを返す場合にEffect.flatMapが使用されます。しかし、両方のシナリオが変換を含むため、Effect モジュールは便利なオールインワンソリューションであるEffect.andThenを公開しています。

Effect.andThen関数は、通常 2 つのEffectのアクションを実行します。2 番目のアクションは、最初のアクションの結果に依存することがあります。

import { pipe, Effect } from "effect";
const transformedEffect = pipe(myEffect, Effect.andThen(anotherEffect));
// または
const transformedEffect = Effect.andThen(myEffect, anotherEffect);
// または
const transformedEffect = myEffect.pipe(Effect.andThen(anotherEffect));

anotherEffectアクションはさまざまな形を取り得ます:

  1. 値(すなわち、Effect.asの同じ機能)
  2. 値を返す関数(すなわち、Effect.mapの同じ機能)
  3. Promise
  4. Promiseを返す関数
  5. Effect
  6. Effectを返す関数(すなわち、Effect.flatMapの同じ機能)

Effect.andThenを使用する代わりにEffect.mapEffect.flatMapを比較する例を見てみましょう。

import { pipe, Effect } from "effect";
// 取引金額に安全に割引を適用する関数
const applyDiscount = (
total: number,
discountRate: number
): Effect.Effect<number, Error> =>
discountRate === 0
? Effect.fail(new Error("割引率はゼロにできません"))
: Effect.succeed(total - (total * discountRate) / 100);
// データベースから取引金額を取得する非同期タスクのシミュレーション
const fetchTransactionAmount = Effect.promise(() => Promise.resolve(100));
// Effect.mapとEffect.flatMapを使用
const result1 = pipe(
fetchTransactionAmount,
Effect.map((amount) => amount * 2),
Effect.flatMap((amount) => applyDiscount(amount, 5))
);
Effect.runPromise(result1).then(console.log); // 出力: 190
// Effect.andThenを使用
const result2 = pipe(
fetchTransactionAmount,
Effect.andThen((amount) => amount * 2),
Effect.andThen((amount) => applyDiscount(amount, 5))
);
Effect.runPromise(result2).then(console.log); // 出力: 190

Option(選択値の処理に一般的に使用される型)やEither(単純なエラーシナリオを処理するための型)がEffect.andThenと互換性があることにも注意が必要です。ただし、これらの型が使用される場合、操作は前述のシナリオ 5 および 6 に分類され、OptionおよびEitherはこの文脈でEffectとして機能します。

Option を使った例

import { pipe, Effect, Option } from "effect";
// データベースから数値を取得する非同期タスクのシミュレーション
const fetchNumberValue = Effect.promise(() => Promise.resolve(42));
// タイプはEffect<Option<number>, never, never>であることが期待されますが、
// 実際にはEffect<number, NoSuchElementException, never>です。
const program = pipe(
fetchNumberValue,
Effect.andThen((x) => (x > 0 ? Option.some(x) : Option.none()))
);

Option<A>は、Effect<A, NoSuchElementException>という型の Effect として解釈されます。

Either を使った例

import { pipe, Effect, Either } from "effect";
// 文字列から整数を解析する関数(失敗する可能性あり)
const parseInteger = (input: string): Either.Either<number, string> =>
isNaN(parseInt(input))
? Either.left("無効な整数")
: Either.right(parseInt(input));
// データベースから文字列を取得する非同期タスクのシミュレーション
const fetchStringValue = Effect.promise(() => Promise.resolve("42"));
// タイプはEffect<Either<number, string>, never, never>であることが期待されますが、
// 実際にはEffect<number, string, never>です。
const program = pipe(
fetchStringValue,
Effect.andThen((str) => parseInteger(str))
);

Either<A, E>は、Effect<A, E>という型の Effect として解釈されます。

tap

Effect.tap API はEffect.flatMapと似た構文を持っていますが、変換関数の結果は無視されます。これは、前の計算から返された値が次の計算にも利用可能であることを意味します。

import { pipe, Effect } from "effect";
// 取引金額に安全に割引を適用する関数
const applyDiscount = (
total: number,
discountRate: number
): Effect.Effect<number, Error> =>
discountRate === 0
? Effect.fail(new Error("割引率はゼロにできません"))
: Effect.succeed(total - (total * discountRate) / 100);
// データベースから取引金額を取得する非同期タスクのシミュレーション
const fetchTransactionAmount = Effect.promise(() => Promise.resolve(100));
const finalAmount = pipe(
fetchTransactionAmount,
Effect.tap((amount) =>
Effect.sync(() => console.log(`割引を適用する: ${amount}`))
),
// `amount`はまだ利用可能です!
Effect.flatMap((amount) => applyDiscount(amount, 5))
);
Effect.runPromise(finalAmount).then(console.log);
/*
出力:
割引を適用する: 100
95
*/

Effect.tapを使用すると、計算中に副作用を実行できますが、結果を変更しません。これは、ログを記録したり、追加のアクションを実行したり、中間値を観察したりする際に便利です。

all

Effect.all関数は、複数の効果を組み合わせて、結果のタプルを生成する単一の効果を生成するための強力なユーティリティです。

Effect.all の使用法

Effect.allの構文は次のとおりです。

import { Effect } from "effect"
const combinedEffect = Effect.all([effect1, effect2, ...])

Effect.all関数は、これらの効果を順次実行します(同時実行の管理やこれらの効果の実行方法を制御するオプションについては、同時実行オプションドキュメントを参照してください)。

この関数は、各個々の効果の結果を含むタプルを生成する新しい効果を返します。結果の順序は、Effect.allに渡された元の効果の順序に対応しています。

import { Effect } from "effect";
// 設定をファイルから読み取るシミュレーション関数
const webConfig = Effect.promise(() =>
Promise.resolve({ dbConnection: "localhost", port: 8080 })
);
// データベース接続の確認を行うシミュレーション関数
const checkDatabaseConnectivity = Effect.promise(() =>
Promise.resolve("データベースに接続しました")
);
// スタートアップチェックを実行するために両方の効果を組み合わせる
const startupChecks = Effect.all([webConfig, checkDatabaseConnectivity]);
Effect.runPromise(startupChecks).then(([config, dbStatus]) => {
console.log(`設定: ${JSON.stringify(config)}, DBステータス: ${dbStatus}`);
});
/*
出力:
設定: {"dbConnection":"localhost","port":8080}, DBステータス: データベースに接続しました
*/

Effect.all関数は、タプルを組み合わせるだけでなく、イテラブル、構造体、およびレコードとも動作します。allの完全な可能性を探るには、Effect の制御フローオペレーターの紹介ドキュメントにアクセスしてください。

最初のパイプラインを構築する

次に、pipeEffect.allEffect.andThenを組み合わせて、一連の変換を実行するパイプラインを構築しましょう。

import { Effect, pipe } from "effect";
// 取引金額に少額の手数料を加算する関数
const addServiceCharge = (amount: number) => amount + 1;
// 取引金額に安全に割引を適用する関数
const applyDiscount = (
total: number,
discountRate: number
): Effect.Effect<number, Error> =>
discountRate === 0
? Effect.fail(new Error("割引率はゼロにできません"))
: Effect.succeed(total - (total * discountRate) / 100);
// データベースから取引金額を取得する非同期タスクのシミュレーション
const fetchTransactionAmount = Effect.promise(() => Promise.resolve(100));
// 設定ファイルから割引率を取得する非同期タスクのシミュレーション
const fetchDiscountRate = Effect.promise(() => Promise.resolve(5));
// 効果のパイプラインを作成
const program = pipe(
Effect.all([fetchTransactionAmount, fetchDiscountRate]),
Effect.flatMap(([transactionAmount, discountRate]) =>
applyDiscount(transactionAmount, discountRate)
),
Effect.map(addServiceCharge),
Effect.map((finalAmount) => `請求額: ${finalAmount}`)
);
// プログラムを実行して結果をログに記録
Effect.runPromise(program).then(console.log); // 出力: "請求額: 96"

pipe メソッド

Effect は、rxjspipeメソッドに似たpipeメソッドも提供しています。このメソッドを使用すると、複数の操作を連鎖させることができ、コードが簡潔で読みやすくなります。

pipe メソッドの使い方は次のとおりです。

const result = effect.pipe(func1, func2, ..., funcN)

これは、次のようにpipe 関数を使用するのと同等です。

const result = pipe(effect, func1, func2, ..., funcN)

pipeメソッドはすべてのEffectおよび多くの他のデータ型で使用でき、Functionモジュールからpipe関数をインポートする必要がなくなり、キーストロークを節約します。

前の例をpipeメソッドを使って書き直してみましょう。

import { Effect } from "effect";
const addServiceCharge = (amount: number) => amount + 1;
const applyDiscount = (
total: number,
discountRate: number
): Effect.Effect<number, Error> =>
discountRate === 0
? Effect.fail(new Error("割引率はゼロにできません"))
: Effect.succeed(total - (total * discountRate) / 100);
const fetchTransactionAmount = Effect.promise(() => Promise.resolve(100));
const fetchDiscountRate = Effect.promise(() => Promise.resolve(5));
// ---cut---
const program = Effect.all([fetchTransactionAmount, fetchDiscountRate]).pipe(
Effect.flatMap(([transactionAmount, discountRate]) =>
applyDiscount(transactionAmount, discountRate)
),
Effect.map(addServiceCharge),
Effect.map((finalAmount) => `請求額: ${finalAmount}`)
);

チートシート

これまで見てきた変換関数をまとめましょう:

関数入力出力
mapEffect<A, E, R>, A => BEffect<B, E, R>
flatMapEffect<A, E, R>, A => Effect<B, E, R>Effect<B, E, R>
andThenEffect<A, E, R>, *Effect<B, E, R>
tapEffect<A, E, R>, A => Effect<B, E, R>Effect<A, E, R>
all[Effect<A, E, R>, Effect<B, E, R>, ...]Effect<[A, B, ...], E, R>

これらの関数は、Effect計算を変換し、連鎖させるための強力なツールです。これらを使用することで、Effect内の値に関数を適用し、複雑な計算のパイプラインを構築できます。