Effect-TS を触ったぼく「十分に高度な関数型は、手続き型と区別できないのでは?」
はじめに
この記事は Commune Developers Advent Calendar 2025 の3日目です。
背景
私たちは普段 TypeScript で開発しているのですが、社内で「関数型プログラミングを試してみよう」という話になり、小さなプロジェクトで Effect-TS を採用してみました。
私は Scala の経験があるためモナドについて一定理解があり、考え方や書き方について一定の納得感があったのですが、関数型に慣れていないメンバーからは困惑が見受けられました。
そこで、関数型に慣れていないメンバーでもキャッチアップしやすいよう実装を整理していたところ、表題のことを考えたので記事にしてみた次第です。
Effect-TSの概要
導入の背景
コードを書いていると適切にエラーハンドリングしたい時はよくあります。
また昨今の TypeScript(JavaScript) では Promise を用いた非同期処理は当たり前になっています。しかし Promise はエラー時の形を持てません。
そのために以下のような型を自作してみたこともありました。
type ResultSuccess<T> = { success: true; value: T }
type ResultFailure<E> = { success: false; error: E }
type Result<T, E = unknown> = ResultSuccess<T> | ResultFailure<E>
しかしこの Result 型の導入では
- エラー処理が毎回冗長である
- Promise.rejectは貫通してしまう
といった問題が残ります。
これを解決するために Effect-TS を導入してみました。
Effect<T, E>
というモナドを用いることで Promise を排除することができ、それによりエラー型を含めて安全に非同期処理を取り扱うことができるようになりました。
Effect の第3パラメータについて補足
Effect-TS では型パラメータとして Effect<Success, Error, Requirements> を取りますが、
この記事では簡略化のため Requirements は省略しています。
コードのイメージ
const z: Effect<Z, SomeError> = action1().pipe(
Effect.flatMap(x => action2(x)),
Effect.flatMap(y => action3(y)),
);
…ちょっと冗長ですね。 flatMap がメソッドチェーンになっていないのもやや書きづらいです。
こんなふうにも書けます。
const z: Effect<Z, SomeError> = Effect.gen(function* () {
const x = yield* action1();
const y = yield* action2(x);
const z = yield* action3(y);
return z
});
おまじないは多いですが、こちらの方が上から下に読めてわかりやすいですね。
他の言語でも同じことが起きているのでは
同じような書き換え(糖衣構文)は他の関数型言語でもあります。
-- Haskell
let z = action1 >>= \x ->
action2 x >>= \y ->
action3 y
let z = do
x <- action1
y <- action2 x
action3 y
// Scala
val z = action1.flatMap { x =>
action2(x).flatMap { y =>
action3(y)
}
}
val z = for {
x <- action1
y <- action2(x)
z <- action3(y)
} yield z
厳密にはモナドではないですが、JavaScriptのPromiseでも似たようなことが書けます。
// JavaScript
const z = action1.then(x => {
return action2(x).then(y => {
return action3(y);
});
});
const x = await action1;
const y = await action2(x);
const z = action3(y);
どういうことか
いずれの場合も、モナド(的なもの)の値取り出しを <- などで行うことで、手続き型のように取り扱うことができています。
モナドの概念は抽象的であり、慣れていないメンバーからすると理解が難しいこともあります。それを糖衣構文によって意識せずとも使えるようになっているとも考えられます。
実際に使ってみてどうだったのか
Effect-TS について、 Generator を駆使してモナドを再現しているのはただただすごいですね。
言語の標準仕様に <- に相当するものが無いので仕方ないのですが、それでもやはりメンバーからは「なんで yield* するの?」という疑問は発生しました。
また多くのライブラリや標準機能などは Promise を使っているので、 Effect への変換を各所で書く必要があります。記述量はなかなかに多くなりました。
補足: Promise と Effect の変換
// Promise -> Effect
const effect: Effect<T, MyError> = Effect.tryPromise({
try: () => promise,
catch: (error: unknown) => new MyError('Error'),
})
// Effect -> Promise
const promise: Promise<T> = Effect.runPromise(effect)
それでも「手続き型っぽく書ける」というのは実現できています。
「チームで書きやすく、また読みやすく保守性が高い」を実現するのが、プログラミング言語の進化の方向性なのでしょう。
関数型プログラミングはその安全性を保ちつつ、手続き型のような読み書きにする(ぱっと見は区別できなくなる)のが向かう先なのかもしれません。
まとめ
モナドを使ったコードは、そのための糖衣構文により手続き型のように書けることがあります。
うまく設計されていれば、慣れていないメンバーであってもモナドを使ったコードを「そういう書き方なんだ」として、モナドを意識することなく手続き型のように記述することができるようになります。
こうして、関数型は手続き型と区別できなくなっていき、関数型の恩恵を自然に享受できるようになるのでしょう。
Discussion