モナドについてのブログ記事を5つ読みました。どれも圏論から始まり、ブリトーの話が出て、以前よりも混乱して終わりました。すべてを飛ばしましょう。

短いバージョンはこうです:モナドは3つの操作をサポートするコンテナです。TypeScriptではすでに2つのモナドを毎日使っています — PromiseArrayです。パターンを見れば、@oofp/coreのすべてのモナドが馴染みのあるものに感じるでしょう。

3つの操作

すべてのモナドには3つのものがあります:

  1. of(a) — 値をコンテナに入れます。
  2. map(f) — 中の値を変換し、コンテナの形を維持します。
  3. 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.resolveofです。.thenmapchainの両方として機能します — 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です。.mapmapです。.flatMapchainです。同じパターンで、異なるコンテナです。

要点は:chainmapの後にflattenを続けたものです。だからflatMapbindとも呼ばれます。新しいコンテナを返す関数を適用し、ネストされた結果をフラット化します。

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

ステップバイステップ:

  1. M.fromNullable(user)usernullまたはundefinedの場合、Nothingを返します。そうでなければ、Just(user)を返します。
  2. M.chain(u => M.fromNullable(u.address)) — ユーザーが存在する場合、アドレスを取得しようとします。アドレスが存在しない場合、チェーン全体がNothingになります。
  3. M.chain(a => M.fromNullable(a.city)) — 都市についても同様です。
  4. M.map(city => city.toUpperCase()) — まだ値がある場合、それを変換します。
  5. M.getOrElse(() => "UNKNOWN") — 値を抽出するか、フォールバックを使用します。

要点:いずれかのステップでNothingが発生すると、その後のすべてのmapchainがスキップされます。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は、chainLeftで短絡するモナドです。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.chainJustNothingになることができます。Nothingになると、残りはスキップされます。
  • Either.chainRightLeftになることができます。エラーが伝播し、残りはスキップされます。
  • 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 フルスタックアプリケーションアーキテクチャ

同期コードにはMaybeEitherから始めましょう。型付きエラー付きの非同期操作が必要になったら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

ドキュメントで各モジュールを確認:

パターンは常に同じです:値をコンテナにラップし、mapで変換し、chainで操作を繋ぎ、境界で結果を抽出します。これを内面化すれば、すべてのモナドは同じテーマのバリエーションに過ぎません。