モナドについてのブログ記事を5つ読みました。どれも圏論から始まり、ブリトーの話が出て、以前よりも混乱して終わりました。すべてを飛ばしましょう。
短いバージョンはこうです:モナドは3つの操作をサポートするコンテナです。TypeScriptではすでに2つのモナドを毎日使っています — PromiseとArrayです。パターンを見れば、@oofp/coreのすべてのモナドが馴染みのあるものに感じるでしょう。
3つの操作
すべてのモナドには3つのものがあります:
-
of(a)— 値をコンテナに入れます。 -
map(f)— 中の値を変換し、コンテナの形を維持します。 -
chain(f)— 値を新しいコンテナに変換し、フラット化します。
これだけです。型がこの3つの操作をサポートし、いくつかの簡単な法則に従っていれば、それはモナドです。すでに知っている型で見てみましょう。
Promise — 毎日使っているモナド
// of — 値をラップする
const p = Promise.resolve(42);
// map — 中身を変換する(thenで値をそのまま返す)
const doubled = p.then((x) => x * 2); // Promise<number>
// chain — 新しいPromiseに変換する(thenでasyncな値を返す)
const fetched = p.then((id) => fetch(`/api/users/${id}`)); // Promise<Response>
Enter fullscreen mode Exit fullscreen mode
Promise.resolveはofです。.thenはmapとchainの両方として機能します — JavaScriptはこれらを混同しています。コールバックがプレーンな値を返す場合はmapで、Promiseを返す場合はchainです。ランタイムは自動的にネストされたPromise<Promise<T>>をPromise<T>にフラット化します。
Array — 見えにくいもう一つのモナド
// of — 値をラップする
const arr = [42];
// map — 各要素を変換する
const doubled = arr.map((x) => x * 2); // [84]
// chain — 各要素を配列に変換し、フラット化する
const expanded = arr.flatMap((x) => [x, x * 10]); // [42, 420]
Enter fullscreen mode Exit fullscreen mode
Array.of(または単に[value])はofです。.mapはmapです。.flatMapはchainです。同じパターンで、異なるコンテナです。
要点は:chainはmapの後にflattenを続けたものです。だからflatMapやbindとも呼ばれます。新しいコンテナを返す関数を適用し、ネストされた結果をフラット化します。
Maybe:nullチェックの排除
パターンを理解したので、実際の問題に適用してみましょう。このコードを考えてみます:
interface User {
name: string;
address?: {
city?: string;
zip?: string;
};
}
function getCityUppercase(user: User | null): string {
if (user === null) return "UNKNOWN";
if (!user.address) return "UNKNOWN";
if (!user.address.city) return "UNKNOWN";
return user.address.city.toUpperCase();
}
Enter fullscreen mode Exit fullscreen mode
1つの値のために3つのnullチェックが必要です。オプショナルチェイニングは役立ちます(user?.address?.city?.toUpperCase() ?? "UNKNOWN")が、合成できません。このロジックの一部を再利用できず、フォールバック値は最後に埋もれています。
Maybeは、値が存在しない可能性があることを表すモナドです。2つの状態があります:Just(value)またはNothingです。同じロジックを見てみましょう:
import * as M from "@oofp/core/maybe";
import { pipe } from "@oofp/core/pipe";
const getCityUppercase = (user: User | null): string =>
pipe(
M.fromNullable(user),
M.chain((u) => M.fromNullable(u.address)),
M.chain((a) => M.fromNullable(a.city)),
M.map((city) => city.toUpperCase()),
M.getOrElse(() => "UNKNOWN"),
);
Enter fullscreen mode Exit fullscreen mode
ステップバイステップ:
-
M.fromNullable(user)—userがnullまたはundefinedの場合、Nothingを返します。そうでなければ、Just(user)を返します。 -
M.chain(u => M.fromNullable(u.address))— ユーザーが存在する場合、アドレスを取得しようとします。アドレスが存在しない場合、チェーン全体がNothingになります。 -
M.chain(a => M.fromNullable(a.city))— 都市についても同様です。 -
M.map(city => city.toUpperCase())— まだ値がある場合、それを変換します。 -
M.getOrElse(() => "UNKNOWN")— 値を抽出するか、フォールバックを使用します。
要点:いずれかのステップでNothingが発生すると、その後のすべてのmapとchainがスキップされます。Nothingは自動的に伝播します。nullチェック、早期リターン、例外はありません。
Maybe API 一覧
import * as M from "@oofp/core/maybe";
M.just(42); // Just(42)
M.nothing(); // Nothing
M.fromNullable(null); // Nothing
M.fromNullable(42); // Just(42)
// 変換
M.map((x) => x + 1); // Just(42) -> Just(43), Nothing -> Nothing
M.chain((x) => M.just(x + 1)); // Just(42) -> Just(43)
// 抽出
M.getOrElse(() => 0); // Just(42) -> 42, Nothing -> 0
M.toNullable; // Just(42) -> 42, Nothing -> null
Enter fullscreen mode Exit fullscreen mode
Either:エラーを値として扱う
Maybeは、何かが欠けているかどうかを教えてくれますが、なぜ欠けているのかは教えてくれません。Either<E, A>はそれを解決します。2つの状態があります:成功時はRight(value)、失敗時はLeft(error)です。エラー型Eはシグネチャに明示的に含まれます。
import * as E from "@oofp/core/either";
import { pipe } from "@oofp/core/pipe";
type ValidationError = { field: string; message: string };
const validateEmail = (input: string): E.Either<ValidationError, string> => {
if (!input.includes("@"))
return E.left({ field: "email", message: "Must contain @" });
return E.right(input);
};
const validateAge = (input: number): E.Either<ValidationError, number> => {
if (input < 0 || input > 150)
return E.left({ field: "age", message: "Out of range" });
return E.right(input);
};
const validateUser = (data: { email: string; age: number }) =>
pipe(
validateEmail(data.email),
E.chain(() => validateAge(data.age)),
E.map(() => data),
);
Enter fullscreen mode Exit fullscreen mode
Eitherは、chainがLeftで短絡するモナドです。validateEmailが失敗した場合、validateAgeは実行されません。エラー型はパイプラインを通じて伝えられ、コンパイラはそれを知っています。
Eitherベースのエラー処理の詳細(非同期操作や回復パターンを含む)については、TypeScriptにおける関数型エラー処理をご覧ください。
Task:遅延非同期
Promiseには問題があります:それは即時的です。作成した瞬間に実行が開始します:
// これは望むと望まざるとに関わらず即座に実行されます
const result = fetch("/api/users");
Enter fullscreen mode Exit fullscreen mode
Promiseを渡すと、その副作用がトリガーされます。Promiseを合成すると、それらが実行されます。これは参照透過性を損ないます — プログラムの動作を変えずに式をその値に置き換えることができません。
Task<A>はこれを解決します。遅延Promiseです:
type Task<A> = () => Promise<A>;
Enter fullscreen mode Exit fullscreen mode
Taskは、呼び出されたときにPromiseを生成する関数です。呼び出すまで何も実行されません。
import * as T from "@oofp/core/task";
import { pipe } from "@oofp/core/pipe";
// 計算を定義する — まだ何も実行されない
const fetchUsers = pipe(
T.of("/api/users"),
T.chain((url) => () => fetch(url)),
T.chain((res) => () => res.json()),
T.map((data) => data.users),
);
// まだ何も起こっていない。
// ここで実行する:
const users = await fetchUsers();
Enter fullscreen mode Exit fullscreen mode
Taskは単なる関数なので、以下が可能です:
- 副作用をトリガーせずに渡す。
- 実行する前に複数のTaskをパイプラインに合成する。
- 他の値に基づいてTaskを再試行、遅延、または条件付きで実行する。
Task API 一覧
import * as T from "@oofp/core/task";
T.of(42); // 42に解決されるTask
T.map((x) => x + 1); // 解決された値を変換する
T.chain((x) => T.of(x + 1)); // 新しいTaskに繋げる
T.delay(1000); // 1秒待ってから解決する
Enter fullscreen mode Exit fullscreen mode
TaskEither:主力ツール
実際のアプリケーションでは、ほとんどの操作が非同期で失敗する可能性があります。TaskEither<E, A>は両方を組み合わせます:
type TaskEither<E, A> = () => Promise<Either<E, A>>;
Enter fullscreen mode Exit fullscreen mode
これは、常にEitherに解決されるPromiseを返す遅延関数です。拒否はなく、未処理のPromiseエラーもありません — 型付きの成功または型付きの失敗だけです。
import * as TE from "@oofp/core/task-either";
import { pipe } from "@oofp/core/pipe";
interface ApiError {
status: number;
message: string;
}
interface User {
id: string;
name: string;
}
interface Order {
id: string;
total: number;
}
const fetchUser = (id: string): TE.TaskEither<ApiError, User> =>
TE.tryCatch(
() =>
fetch(`/api/users/${id}`).then(async (res) => {
if (!res.ok) throw { status: res.status, message: res.statusText };
return res.json() as Promise<User>;
}),
(err) => err as ApiError,
);
const fetchOrders = (userId: string): TE.TaskEither<ApiError, Order[]> =>
TE.tryCatch(
() =>
fetch(`/api/orders?user=${userId}`).then(async (res) => {
if (!res.ok) throw { status: res.status, message: res.statusText };
return res.json() as Promise<Order[]>;
}),
(err) => err as ApiError,
);
// 完全なワークフローを合成する
const getUserDashboard = (userId: string) =>
pipe(
fetchUser(userId),
TE.chain((user) =>
pipe(
fetchOrders(user.id),
TE.map((orders) => {
user,
orders,
totalSpent: orders.reduce((sum, o) => sum + o.total, 0),
})),
),
),
);
// まだ実行されていない。実行する:
const result = await getUserDashboard("user-123")();
Enter fullscreen mode Exit fullscreen mode
tryCatchは、Promiseを返す関数(拒否する可能性がある)を受け取り、決して拒否しないTaskEitherにラップします。第2引数はキャッチしたエラーを型付きエラーにマッピングします。
これは本番のTypeScriptで最も使用するモナドです。再試行戦略、orElseによるエラー回復、concurrencyObjectによる同時実行については、エラー処理ガイドをご覧ください。
chainパターン
すべてを結びつける重要な洞察です。chainがモナドを強力にします。各ステップがコンテナの状態を変更できるシーケンシャルな合成です:
-
Maybe.chain—JustはNothingになることができます。Nothingになると、残りはスキップされます。 -
Either.chain—RightはLeftになることができます。エラーが伝播し、残りはスキップされます。 -
Task.chain— 1つの非同期操作が次の操作に結果を渡します。構造上シーケンシャルです。 -
TaskEither.chain— 失敗する可能性がある非同期操作をシーケンスします。1つのステップでLeftが発生すると、後続のステップはスキップされます。
すべてのモナドが同じ構造に従います。chainを一度学べば、どんなモナドでも使えます。
複数のモナドを流れる実用的な例です:
import * as M from "@oofp/core/maybe";
import * as E from "@oofp/core/either";
import * as TE from "@oofp/core/task-either";
import { pipe } from "@oofp/core/pipe";
// 環境から設定を解析する(同期、欠落している可能性あり)
const getConfig = (env: Record<string, string | undefined>) =>
pipe(
M.fromNullable(env.API_URL),
M.chain((url) =>
pipe(
M.fromNullable(env.API_KEY),
M.map((key) => ({ url, key })),
),
),
);
// 設定を検証する(同期、理由付きで失敗する可能性あり)
const validateConfig = (config: { url: string; key: string }) =>
pipe(
config.url.startsWith("https")
? E.right(config)
: E.left("API_URL must use HTTPS"),
E.chain((c) =>
c.key.length >= 32 ? E.right(c) : E.left("API_KEY too short"),
),
);
// 検証済み設定を使ってデータを取得する(非同期、失敗する可能性あり)
const fetchData = (config: { url: string; key: string }) =>
TE.tryCatch(
() =>
fetch(config.url, {
headers: { Authorization: `Bearer ${config.key}` },
}).then((r) => r.json()),
(err) => `Fetch failed: ${String(err)}`,
);
Enter fullscreen mode Exit fullscreen mode
3つの異なるモナド、3つの異なる懸念事項(欠落値、検証エラー、非同期失敗)ですが、chainパターンは一貫しています。各関数は小さく、テスト可能で、合成可能です。
pipe:すべてを結びつける
すべての例でpipeを使っていることに気づいたかもしれません。これがないと、モナドコードは深くネストしてしまいます:
// pipeなし — ネストして読みにくい
const result = M.getOrElse(() => "UNKNOWN")(
M.map((city: string) => city.toUpperCase())(
M.chain((a: { city?: string }) => M.fromNullable(a.city))(
M.chain((u: User) => M.fromNullable(u.address))(
M.fromNullable(user)
)
)
)
);
Enter fullscreen mode Exit fullscreen mode
pipeを使えば、同じロジックが上から下、左から右に読めます:
// pipeあり — 線形で明確
const result = pipe(
M.fromNullable(user),
M.chain((u) => M.fromNullable(u.address)),
M.chain((a) => M.fromNullable(a.city)),
M.map((city) => city.toUpperCase()),
M.getOrElse(() => "UNKNOWN"),
);
Enter fullscreen mode Exit fullscreen mode
pipeは初期値を受け取り、一連の関数に通します。各関数は前の関数の出力を受け取ります。これは@oofp/coreにおける合成コードの基盤です。
import { pipe } from "@oofp/core/pipe";
// pipe(value, f1, f2, f3) === f3(f2(f1(value)))
const result = pipe(
5,
(x) => x * 2, // 10
(x) => x + 1, // 11
(x) => `${x}!`, // "11!"
);
Enter fullscreen mode Exit fullscreen mode
何をいつ使うか
| 状況 | モナド | 理由 |
|---|---|---|
| 値が存在しない可能性がある | Maybe |
nullチェックを排除し、欠如を伝播する |
| 操作が失敗する可能性がある(同期) | Either |
関数シグネチャに型付きエラー |
| 非同期操作 | Task |
遅延、合成可能、参照透過性がある |
| 非同期 + 失敗する可能性あり | TaskEither |
実世界アプリの主力ツール |
| ランタイムコンテキストが必要 | Reader |
グローバルを使わない依存性注入 |
| 非同期 + 失敗 + コンテキスト | ReaderTaskEither |
フルスタックアプリケーションアーキテクチャ |
同期コードにはMaybeとEitherから始めましょう。型付きエラー付きの非同期操作が必要になったらTaskEitherに移行します。設定、データベース接続、その他のコンテキストをアプリケーション全体に通す必要がある場合はReaderTaskEitherを使いましょう。
はじめに
@oofp/coreをインストール:
npm install @oofp/core
Enter fullscreen mode Exit fullscreen mode
必要なものをインポート:
import * as M from "@oofp/core/maybe";
import * as E from "@oofp/core/either";
import * as T from "@oofp/core/task";
import * as TE from "@oofp/core/task-either";
import { pipe } from "@oofp/core/pipe";
Enter fullscreen mode Exit fullscreen mode
ドキュメントで各モジュールを確認:
- Maybe — nullなしのオプショナル値
- Either — 同期エラー処理
- Task — 遅延非同期計算
- TaskEither — 非同期エラー処理
- Pipe, Flow & Compose — 関数合成
パターンは常に同じです:値をコンテナにラップし、mapで変換し、chainで操作を繋ぎ、境界で結果を抽出します。これを内面化すれば、すべてのモナドは同じテーマのバリエーションに過ぎません。
0 Comments
Log in to join the conversation.No comments yet. Be the first to share your thoughts.