このコードを見て、処理順序を教えてください:
const result = uppercase(trim(removeSpaces(validate(input))));
Enter fullscreen mode Exit fullscreen mode
左から右に読みますが、実行は右から左に行われます。まず validate が実行され、次に removeSpaces、trim、uppercase の順に実行されます。読み取り順序と実行順序は逆になっています。
もう1ステップ追加してみましょう:
const result = format(uppercase(trim(removeSpaces(validate(input)))));
Enter fullscreen mode Exit fullscreen mode
さらに追加:
const result = encode(format(uppercase(trim(removeSpaces(validate(input))))));
Enter fullscreen mode Exit fullscreen mode
これはスケールしません。新しいステップを追加するたびに、式全体を別の関数呼び出しでラップすることになります。ネストが増え、括弧が積み重なります。途中にステップを挿入するには括弧を数える必要があります。削除する場合も同様です。そして、これらの関数のいずれかが Either や Task のようなラッパー型を返す場合、ネストは本当に厄介になります。
これを書くためのより良い方法があります。
pipe: 左から右への合成
pipe は最初の引数として値を受け取り、一連の関数を左から右へ渡します。各関数は前の関数の戻り値を受け取ります。
import { pipe } from "@oofp/core/pipe";
const result = pipe(input, validate, removeSpaces, trim, uppercase);
Enter fullscreen mode Exit fullscreen mode
同じ操作、同じ結果。しかし今度はコードが実行順序で読めます: input から始めて、検証し、スペースを削除し、トリミングし、大文字に変換します。
ステップの追加は簡単です:
const result = pipe(input, validate, removeSpaces, trim, uppercase, encode);
Enter fullscreen mode Exit fullscreen mode
削除も簡単 — 行を削除するだけです。括弧を数える必要もありません。構造を変更する必要もありません。
チェーンを通じた型推論
TypeScript はすべての中間型を推論します。もし validate が string を返すなら、removeSpaces は string を受け入れる必要があります。マッチしない関数を渡すと、コンパイラはすぐに教えてくれます:
const double = (n: number) => n * 2;
const exclaim = (s: string) => s + "!";
const len = (s: string) => s.length;
// TypeScript infers each step:
const result = pipe(
"hello", // string
exclaim, // (string) => string
len, // (string) => number
double, // (number) => number
);
// result: number (10)
Enter fullscreen mode Exit fullscreen mode
double と len を入れ替えると、コンパイラがそれを検出します。型はパイプラインを通じて流れ、すべての不一致はコンパイル時に表面化します。
垂直パイプライン
パイプラインが長くなったら、縦に書きます。これは関数型 TypeScript の慣用的なスタイルです:
import { pipe } from "@oofp/core/pipe";
import * as E from "@oofp/core/either";
const processInput = (raw: string) =>
pipe(
raw,
parseJSON,
E.chain(validateSchema),
E.map(normalize),
E.map(enrichWithDefaults),
E.map(serialize),
);
Enter fullscreen mode Exit fullscreen mode
各行が1つのステップです。パイプラインを上から下へスキャンして、変換を一目で理解できます。
flow: 再利用可能なパイプラインの作成
pipe は最初に値を受け取り、すぐに実行します。時には値がまだない場合もあります — パイプラインを定義して後で適用したい場合です。それが flow です。
flow は関数のみを受け取り、新しい関数を返します:
import { flow } from "@oofp/core/flow";
const process = flow(validate, removeSpaces, trim, uppercase);
// process is a function: (input: string) => string
const result = process(input);
Enter fullscreen mode Exit fullscreen mode
flow は pipe と同じく左から右へ合成します。違いは計算がいつ行われるかです: pipe は今実行し、flow は後で適用するための関数を構築します。
flow の強み
flow は変換を引数として渡す必要がある場合に使用します:
import { flow } from "@oofp/core/flow";
import * as E from "@oofp/core/either";
const parsePositiveNumber = flow(
(s: string) => Number(s),
(n) => (isNaN(n) ? E.left("Not a number") : E.right(n)),
E.chain((n) => (n > 0 ? E.right(n) : E.left("Must be positive"))),
);
// Use it directly with Array.map
const results = ["10", "-3", "abc", "42"].map(parsePositiveNumber);
// [Right(10), Left("Must be positive"), Left("Not a number"), Right(42)]
Enter fullscreen mode Exit fullscreen mode
flow はプレーンな関数を返すため、コールバックを受け入れる任意の API と統合できます: Array.map、Array.filter、イベントハンドラ、ミドルウェアチェーンなど。
パイプラインへの命名
flow は変換に名前を付けることを奨励します。これは可読性の向上です:
import { flow } from "@oofp/core/flow";
import * as M from "@oofp/core/maybe";
const parseAge = flow(
M.fromNullable<string>,
M.map((s) => parseInt(s, 10)),
M.iif((n) => !isNaN(n) && n >= 0 && n <= 150),
);
const formatCurrency = flow(
(cents: number) => cents / 100,
(dollars) => dollars.toFixed(2),
(s) => `$${s}`,
);
Enter fullscreen mode Exit fullscreen mode
今、parseAge と formatCurrency は自己文書化され、再利用可能で、独立してテスト可能です。
compose: 右から左 (数学的順序)
compose は flow と同じことをしますが、逆の順序で行います。関数は数学的表記法に合わせて外側から最初にリストされ、(f . g)(x) = f(g(x)) となります。
import { compose } from "@oofp/core/compose";
const process = compose(uppercase, trim, removeSpaces, validate);
// Same result as flow(validate, removeSpaces, trim, uppercase)
Enter fullscreen mode Exit fullscreen mode
右から左に読んでください: 検証、スペース削除、トリミング、大文字変換。
import { flow } from "@oofp/core/flow";
import { compose } from "@oofp/core/compose";
const exclaim = (s: string) => s + "!";
const upper = (s: string) => s.toUpperCase();
const trim = (s: string) => s.trim();
// These produce identical functions:
const withFlow = flow(trim, upper, exclaim);
const withCompose = compose(exclaim, upper, trim);
withFlow(" hi "); // "HI!"
withCompose(" hi "); // "HI!"
Enter fullscreen mode Exit fullscreen mode
ほとんどの TypeScript 開発者は pipe と flow を好みます。左から右がコードの読み方に一致するからです。compose は Haskell や圏論から来て f . g の考え方をする人のために存在します。チームにとって読みやすい方を使ってください。
実世界の例
データ検証パイプライン
import { pipe } from "@oofp/core/pipe";
import * as E from "@oofp/core/either";
interface Order {
items: Array<{ price: number; qty: number }>;
discount: number;
}
const validateOrder = (data: unknown): E.Either<string, Order> =>
typeof data === "object" && data !== null
? E.right(data as Order)
: E.left("Invalid order data");
const calculateTotal = (order: Order): Order & { total: number } => ({
...order,
total: order.items.reduce((sum, item) => sum + item.price * item.qty, 0),
});
const applyDiscount = (rate: number) => (order: Order & { total: number }) => ({
...order,
total: order.total * (1 - rate),
});
const processOrder = (rawData: unknown) =>
pipe(
rawData,
validateOrder,
E.map(calculateTotal),
E.map(applyDiscount(0.1)),
);
Enter fullscreen mode Exit fullscreen mode
各ステップは1つのことを行います。パイプラインが合成です。税計算の追加は1行です: E.map(applyTax(0.08))。
TaskEither を使った非同期パイプライン
import { pipe } from "@oofp/core/pipe";
import * as TE from "@oofp/core/task-either";
interface User {
id: string;
name: string;
}
interface Order {
id: string;
userId: string;
total: number;
}
const fetchUser = (id: string): TE.TaskEither<Error, User> =>
TE.tryCatch(
() => fetch(`/api/users/${id}`).then((r) => r.json()),
(err) => new Error(`Failed to fetch user: ${err}`),
);
const fetchOrders = (userId: string): TE.TaskEither<Error, Order[]> =>
TE.tryCatch(
() => fetch(`/api/orders?userId=${userId}`).then((r) => r.json()),
(err) => new Error(`Failed to fetch orders: ${err}`),
);
const calculateTotals = (orders: Order[]) =>
orders.reduce((sum, o) => sum + o.total, 0);
const formatReport = (total: number) => `Total revenue: $${total.toFixed(2)}`;
const fetchAndProcess = (id: string) =>
pipe(
fetchUser(id),
TE.chain((user) => fetchOrders(user.id)),
TE.map(calculateTotals),
TE.map(formatReport),
);
Enter fullscreen mode Exit fullscreen mode
fetchUser が失敗した場合、fetchOrders は実行されません。エラーは try-catch ブロックなしでパイプラインを通じて伝播します。
pipe と flow の組み合わせ
一般的なパターン: flow で小さなパイプラインを定義し、pipe でそれらを編成します:
import { pipe } from "@oofp/core/pipe";
import { flow } from "@oofp/core/flow";
import * as E from "@oofp/core/either";
const parseNumber = flow(
(s: string) => Number(s),
(n) => (isNaN(n) ? E.left("Invalid number" as const) : E.right(n)),
);
const ensureRange = (min: number, max: number) =>
flow(
E.iif((n: number) => n >= min && n <= max),
E.mapLeft(() => `Must be between ${min} and ${max}` as const),
);
const parsePercentage = (input: string) =>
pipe(
parseNumber(input),
E.chain(ensureRange(0, 100)),
);
parsePercentage("42"); // Right(42)
parsePercentage("-5"); // Left("Must be between 0 and 100")
parsePercentage("abc"); // Left("Invalid number")
Enter fullscreen mode Exit fullscreen mode
flow はビルディングブロックを作成します。pipe はそれらを組み立てます。
pipe vs メソッドチェーン
「これは .then().then() や array.map().filter() のように見える」と考えているかもしれません。メソッドチェーンと pipe は似た問題を解決しますが、pipe はより汎用的です。
メソッドチェーンは特定のクラスで定義されたメソッドでのみ機能します:
// Method chaining — only works with Array's built-in methods
const result = [1, 2, 3, 4, 5]
.filter((n) => n > 2)
.map((n) => n * 2)
.reduce((sum, n) => sum + n, 0);
Enter fullscreen mode Exit fullscreen mode
チェーンの途中で別のモジュールからのユーティリティ関数を呼び出す必要がある場合はどうでしょうか? できません。チェーンを切断し、変数に代入し、新しいチェーンを開始する必要があります。
pipe は任意のモジュールの任意の関数で機能します:
import { pipe } from "@oofp/core/pipe";
import * as L from "@oofp/core/list";
const result = pipe(
[1, 2, 3, 4, 5],
L.filter((n) => n > 2),
L.map((n) => n * 2),
L.reduce(0, (sum, n) => sum + n),
formatAsUSD, // from your utils module
addTaxDisclaimer, // from another module
);
Enter fullscreen mode Exit fullscreen mode
任意のソースからの関数を混在させることができます。クラス階層はありません。プロトタイプチェーンはありません。ただ関数が入り、値が出るだけです。
これによりテストも簡単になります。パイプライン内の各関数は独立してテストできるスタンドアロンユニットです。クラスをインスタンス化したり、メソッドをモックしたりする必要はありません。
TC39 パイプライン演算子
JavaScript にはパイプライン演算子 |> の stage 2 提案 があります:
// Proposed syntax (not yet available)
const result = input
|> validate(%)
|> removeSpaces(%)
|> trim(%)
|> uppercase(%);
Enter fullscreen mode Exit fullscreen mode
これは pipe が解決するのと同じ問題を解決します。しかし2025年現在、この提案は数年間 stage 2 のままです。実験的な Babel プラグインでのトランスパイルが必要です。複数の構文改訂を経ており、TypeScript がすでに提供する以上の型安全性は提供しません。
@oofp/core/pipe の pipe は今日同じ人間工学を提供します:
- ゼロ設定で任意の TypeScript プロジェクトで動作します。
- チェーン全体を通じた完全な型推論。
- 実験フラグやトランスパイラープラグインは不要です。
- 委員会の承認を待たずに今すぐ利用可能です。
パイプライン演算子が最終的に言語に組み込まれた場合、pipe からの移行は簡単です。それまでは、pipe が実用的な選択肢です。
それぞれの使い方
| ユーティリティ | 入力 | 戻り値 | 使用する場合 |
|---|---|---|---|
pipe(value, f, g, h) |
値 + 関数 | 最終結果 | 値があり、今それを変換したい場合 |
flow(f, g, h) |
関数のみ | 新しい関数 | 後で適用するための再利用可能なパイプラインが欲しい場合 |
compose(h, g, f) |
関数のみ (逆順) | 新しい関数 | 右から左 (数学的) 順序が欲しい場合 |
決定木はシンプルです:
-
すでに値がある?
pipeを使います。 -
再利用可能な変換を構築中?
flowを使います。 -
数学的表記法を好む?
composeを使います。
実際には、pipe を約80%の時間、flow を約19%、compose を稀に使用します。それで問題ありません。すべてに完全な TypeScript 推論があり、すべてがクリーンに合成され、すべてが @oofp/core から来ています。
はじめに
コアパッケージをインストールします:
npm install @oofp/core
Enter fullscreen mode Exit fullscreen mode
必要なものをインポートします:
import { pipe } from "@oofp/core/pipe";
import { flow } from "@oofp/core/flow";
import { compose } from "@oofp/core/compose";
Enter fullscreen mode Exit fullscreen mode
各ユーティリティのより深い理解とさらなる例、API 詳細については、Pipe, Flow & Compose ドキュメント を参照してください。
pipe から始めましょう。コードベースのネストされた関数呼び出しを1つ置き換えてみてください。読み取り順序が実行順序と一致したら、もう元に戻りたくなくなるでしょう。
0 Comments
Log in to join the conversation.No comments yet. Be the first to share your thoughts.