
本記事は、M3 Advent Calendar 2025 14日目の記事です。
はじめまして。エンジニアグループ、コンシューマーチームの松本と申します。
今回は、「継続 - Continuation」の本質を理解し、Promiseやasync/awaitでは解決できない課題を明らかにした上で、それを乗り越えるEffect Systemについて解説します。
「継続 - Continuation」とは、プログラムの「残りの計算」を表現する概念です。
こう聞くと、なんだか難しく聞こえますが、具体例を見ればシンプルです。
早速、具体的な例を見てみましょう。
functioncalculate(){const a =3 +4;console.log(a);// これ以後の計算が継続const b = a *2;return b;}
どこの教科書にでも載っていそうな四則演算の例ですが、console.log(a) の行を実行した後、その後の計算(const b = a * 2 以降)が「継続」となります。
どんなプログラムにも、各行の後に「残りの計算」が存在します。この例では、console.log(a) がプログラムの外部に影響を与える処理(副作用)であるため、処理が一旦そこで中断される可能性があり、その後の継続が意識されやすくなっています。
もう1つ例を見てみましょう。
functionfetchData(){fetch('https://api.example.com/data') .then(response=> response.json()) .then(data=>{console.log(data);// これ以後の計算が継続return data.value;});}
これは逆にわかりやすいですね。fetch はネットワーク越しにデータを取得する非同期処理です。fetch の結果が返ってくるまで待って、その後の計算(console.log(data) 以降)が「継続」となります。
つまり、プログラムのあらゆる箇所に「残りの計算」としての継続が存在します。特に、処理が中断される箇所では、その後の処理が明示的に「継続」として表れるわけです。
他の例も見てみましょう。
// 普通の書き方const result = readFile('data.txt');console.log(result);processData(result);// コールバック版readFile('data.txt',function(result){console.log(result);// ← この関数全体が「継続」 processData(result);// ← ファイルを読んだ後の「残りの計算」});
上記の例では、readFile関数に、読み込むファイル名と、読み込んだあとに実行される処理(コールバック関数)を渡しています。つまり、コールバック関数とは、継続を明示的に関数にしたもの と捉えることができます。
上記のreadFileは、ファイルの読み込み箇所で処理が一旦「中断」されます。無事に読み込みが成功すれば、指定された「継続」処理が実行され、成功しない場合はそのまま終了します。
この例は、厳密には限定継続(delimited continuation)です。(継続には、プログラム全体の残りを表す「継続」と、特定の範囲までの残りを表す「限定継続」があります。コールバック関数やPromiseの.then()は、その関数やブロックの範囲内だけの計算を表すため、限定継続にあたります。本記事では簡潔さのため、単に「継続」と記します。)
では、「処理が中断される箇所」とは具体的に何でしょうか? 本記事では、特に副作用を伴う処理に注目します。
副作用を伴う処理とは、プログラムの外部に影響を与える処理のことです。
等など、副作用を伴う処理は多岐にわたります。これらの処理では、完了するまで待つ必要があり、その後の計算(継続)が明示的に意識されます。
ここまで、当たり前なことをさも意味ありげに書いてきましたが、なぜこの継続が重要なのでしょうか?
副作用を伴う処理は、プログラムの制御フローを複雑にします。
まずは次の例を見てください。APIから何かしらのデータを取得し、加工して保存するプログラムの疑似コードです。
functionprocessData( onSuccess:()=>void, onError:(error:Error)=>void){ fetchData('https://api.example.com/data', (response)=>{if (!response.ok){ onError(newError('Network response was not ok'));return;} parseJSON(response, (data)=>{const processed = processDataInternal(data);if (!processed){ onError(newError('Data processing failed'));return;} saveToDatabase(processed, (result)=>{if (!result.success){ onError(newError('Failed to save data'));return;}console.log('Data saved'); onSuccess();}, onError );}, onError );}, onError );}
この例では、副作用を伴う処理(fetchData、parseJSON、saveToDatabase)が複数回登場します。そのたびに、処理が中断され、継続(コールバック)が実行されます。
しかし、このコードにはいくつか問題があります。
この問題を解決するために、継続を明示的に扱う方法を見ていきましょう。
継続渡しスタイル (CPS) とは、関数が結果を直接返すのではなく、結果を受け取るための継続関数を引数として受け取るスタイルです。これにより、非同期処理や副作用を伴う処理をより柔軟に扱うことができます。
早速例を見てみましょう。
// 通常のスタイルfunctionadd(a:number, b:number):number{return a + b;}functionmultiply(a:number, b:number):number{return a * b;}const result = multiply(add(3,4),2);// (3 + 4) * 2 = 14console.log(result);
ここでは2つの処理、つまり足し算を実行してから掛け算を実行するという流れを、2つの関数に分けて書いています。これを継続渡しスタイルで記述します。
// 継続渡しスタイル (CPS)functionaddCPS(a:number, b:number, continuation:(result:number)=>void):void{ continuation(a + b);// 結果を継続に渡す}functionmultiplyCPS(a:number, b:number, continuation:(result:number)=>void):void{ continuation(a * b);}addCPS(3,4, (sum)=>{// sum = 7 multiplyCPS(sum,2, (result)=>{// result = 14console.log(result);});});
継続渡しの特徴は、「次に実行する処理(継続)」を引数として渡すことです。これにより、関数の呼び出し順序や制御フローを柔軟に操作できます。これをもとに、先程のサンプルを書き換えてみましょう。
functionfetchDataCPS( url:string, cont:(data:any)=>void, errCont:(error:Error)=>void){// 注: fetch自体はPromiseを返すため、内部ではPromiseを使用していますが、// 外部インターフェースはCPS(継続を引数として受け取る)になっていますfetch(url) .then(response=>{if (!response.ok){thrownewError('Network response was not ok');}return response.json();}) .then(data=> cont(data)) .catch(error=> errCont(error));}functionexampleCPS( cont:()=>void, errCont:(error:Error)=>void){ fetchDataCPS('https://api.example.com/data', (data)=>{const processed = processDataInternal(data);if (!processed){return errCont(newError('Data processing failed'));} saveToDatabaseCPS( processed, (result)=>{if (!result.success){return errCont(newError('Failed to save data'));}console.log('Data saved'); cont();}, errCont );}, errCont );}
この例では、処理に必要な引数の他に、成功時に継続する処理(cont)と失敗時に継続する処理(errCont)を渡しています。
CPSを用いることで、非同期処理や副作用を伴う処理を明示的に扱うことができます。しかし、CPSにも問題があります。
先ほどのCPSの例を、さらに処理を追加してネストさせてみましょう。
// データ取得 → 処理 → DB保存 → 通知 という一連の流れ// (エラーハンドリングは簡潔にするため省略)fetchDataCPS('https://api.example.com/data', (data)=>{const processed = processDataInternal(data); saveToDatabaseCPS( processed, (result)=>{// さらに処理を続けるとネストが深くなる... notifyUserCPS( result.id, (notification)=>{console.log('Data saved and user notified');} );} );});
このように、継続をネストして書かなければならないため、コードが横に広がり、可読性が著しく低下します。
どうすればいいでしょうか? そこで、きっと親の顔より見たであろうPromiseとasync/awaitが登場します。
継続は合成できます。合成とは、複数の継続をつなげて一連の処理を作ることです。まずは、Promiseを使って合成します。
Promiseを使うと、継続を横に並べて合成できます。
fetchData('https://api.example.com/data') .then(data=> processDataInternal(data))// 継続1: 処理 .then(processed=> saveToDatabase(processed))// 継続2: DB保存 .then(result=> notifyUser(result.id))// 継続3: 通知 .then(notification=>{console.log('Data saved and user notified');});
Promise チェーンを用いることで、処理をつなげて合成していくことができます。しかし、まだ問題があります。ずっとthen でメソッド呼び出しが繋がっていくので、コードが縦に長くなり、可読性が低下してしまいます。
そこで、Promiseチェーンを、より可読性を高めるために登場した糖衣構文がasync/awaitです。
先程の例をasync/awaitで書き換えるとこうなります。
asyncfunctionprocessDataFlow(){const data =await fetchData('https://api.example.com/data');const processed = processDataInternal(data);// 継続1: 処理const result =await saveToDatabase(processed);// 継続2: DB保存const notification =await notifyUser(result.id);// 継続3: 通知console.log('Data saved and user notified');}
async/await は非同期通信を書くための構文ではなく、継続を普通のコードのように書けるようにした糖衣構文だったわけですね。つまり、try/catch、Promise、async/await は、すべて「成功継続」と「失敗継続」という2つの継続を管理する仕組みと捉えることができます。
ここまで、継続の概念とその活用方法を見てきました。Promise や async/await を使えば、継続を読みやすく合成できます。
しかし、実際のアプリケーション開発では、Promise だけでは解決できない課題があります。
asyncfunctionfetchUser(id:string):Promise<User>{const response =awaitfetch(`/api/users/${id}`);if (!response.ok){thrownewError('Failed to fetch user');// ← どんなエラー?}return response.json();}asyncfunctionprocessUser(id:string){try{const user =await fetchUser(id);// userを処理...}catch (error){// error は unkown 型 - どんなエラーが来るか不明console.error(error);}}
最大の問題点は、どんなエラーが発生しうるか、コードを読まないとわからないという事です。いや、そんなの当たり前じゃないか? と思うかもしれません。
例えば、ユニットテストなどで、発生しうるエラーを想定したテストケースを作成し、その時どのような挙動をするのかテストしたりするでしょう。しかし、そこに漏れがあったらどうでしょうか? テストのテスト、メタ的なテストが必要になるのでしょうか?
「いやいや、上の例はanyを使ってるからだめなのであって、はっきりどんなError型かを書けばいいのでは?」と思うかもしれません。
では、型を明記してみましょう:
class NetworkErrorextendsError{constructor(message:string){super(message);this.name ='NetworkError';}}class ParseErrorextendsError{constructor(message:string){super(message);this.name ='ParseError';}}asyncfunctionfetchUser(id:string):Promise<User>{const response =awaitfetch(`/api/users/${id}`);if (!response.ok){thrownew NetworkError('Failed to fetch user');}const data =await response.json();if (!data.id){thrownew ParseError('Invalid user data');}return dataasUser;}asyncfunctionprocessUser(id:string){try{const user =await fetchUser(id);// userを処理...}catch (error){// ここで NetworkError と ParseError を処理すべきだが...console.error(error);}}
一見良さそうに見えますが、TypeScriptの型システムでは、この関数がどんなエラーを投げるか型シグネチャに現れません:
// 型シグネチャasyncfunctionfetchUser(id:string):Promise<User>// ^^^^^^^^^^^^// エラーの情報がない!
つまり、呼び出し側では:
fetchUser がNetworkError とParseError を投げることを 型から知ることができないNetworkError の処理を書き忘れても、コンパイルエラーにならないという事態が発生します。
さらに、複数のエラーが発生しうる場合を考えてみましょう:
asyncfunctionsaveUser(user:User):Promise<void>{// バリデーションエラーが起きるかもしれない validateUser(user);// throws ValidationError// DBエラーが起きるかもしれないawait db.save(user);// throws DbError// ネットワークエラーが起きるかもしれないawait notifyServer(user);// throws NetworkError}// 呼び出し側:3種類のエラーが起きる可能性があるが...try{await saveUser(user);}catch (error){// error は any 型// ValidationError? DbError? NetworkError?// コンパイラは3種類のエラーがあることを知らない// ValidationError の処理を書き忘れても、コンパイルエラーにならない!if (errorinstanceof DbError){// DB エラー処理}// ← NetworkError と ValidationError の処理漏れ!// でもコンパイラは何も警告してくれない}
しかし、この実装にも問題があります。3種類のエラーが発生しうるのに、型シグネチャには現れません。つまり、DbError だけ処理して、他の2つを忘れてもコンパイルエラーにならないのです。テストで全エラーケースをカバーしたか、人間が確認するしかありません。
asyncfunctionprocessFile(path:string):Promise<void>{const file =await openFile(path);try{const data =await file.read();await processData(data);}finally{await file.close();// ← 手動でクローズが必要}}
もし finally を書き忘れたらどうなるでしょうか。もちろん、ファイルハンドルがリークします。もちろんユニットテストをしっかり書いたり、レビューしたりすればよいのですが、これは経験上、忘れられることが多々あります。
また、複数のリソースを扱うと、これまたネストが深くなってしまいます。
と、このように継続は、現代のプログラミングを支える非常に大事な概念ですが、TypeScriptで扱える仕組み(Promise, async/await 等)だけでは限界があることもまた事実です。
これらの課題を解決するために、継続をより強力に型で管理する仕組みが必要になります。その中の一つとして、本記事ではEffect System に着目しています。
Effect System は、副作用(Effect)をうまく扱うための仕組みです。(型システムと組み合わせることが多いですが、必ずしも型が必要というわけではありません)
TypeScript ではEffect-TS というライブラリの実装が代表例に上がるでしょうか。
Effect System の核となる型は次の形式です:
Effect<Success,Error,Requirements>// ^^^^^^^ ^^^^^ ^^^^^^^^^^^^// 成功時の型 失敗時の型 必要な依存
この型シグネチャにより、関数がどんなエラーを投げうるか、何に依存しているかが、型として明示されるのが最大の特徴です。
Promise との比較:
// Promise: エラー型が不明asyncfunctionfetchUser(id:string):Promise<User>// Effect: エラー型と依存が明示的functionfetchUser(id:string):Effect<User,NetworkError|ParseError,never>
先ほど見た3つの課題を、Effect Systemがどう解決するか見てみましょう。
// Promise: エラー型がわからないasyncfunctionfetchUser(id:string):Promise<User>// Effect: エラー型が明示的functionfetchUser(id:string):Effect<User,NetworkError|ParseError,never>// ^^^^^^^^^^^^^^^^^^^^^^^^^^^// どんなエラーが起きるか一目瞭然
型シグネチャを見るだけで、この関数がNetworkError またはParseError を投げる可能性があることがわかります。
const program = fetchUser("123").pipe( Effect.catchTag("NetworkError", (e)=>/* 処理 */),// ParseError の処理を忘れると、コンパイルエラー!)// エラー: Property 'ParseError' is missing
すべてのエラーを処理すると、エラー型はnever になります。処理漏れがあると、型エラーとして検出されます。
const program = Effect.acquireRelease( openFile(path),// 確保 (file)=> file.close()// 解放(自動で実行される)).pipe( Effect.flatMap(file=> processFile(file)))// エラーが起きても、必ずクローズされる
acquireRelease を使うことで、finally を書かなくても、確実にリソースが解放されます。
それでは、実際に Effect-TS を使って、先ほどの課題を解決するコードを書いてみましょう。
import{ Effect, Data}from"effect"// 1. エラー型を定義class NetworkErrorextends Data.TaggedError("NetworkError")<{readonlymessage:string}>{}class ParseErrorextends Data.TaggedError("ParseError")<{readonlymessage:string}>{}class ValidationErrorextends Data.TaggedError("ValidationError")<{readonlymessage:string}>{}// 2. ユーザー取得関数(エラー型が型シグネチャに現れる)const fetchUser = (id:string):Effect.Effect<User,NetworkError|ParseError>=> Effect.gen(function* (_){// ネットワークリクエストconst response = yield* _( Effect.tryPromise({try: ()=>fetch(`/api/users/${id}`),catch: (error)=>new NetworkError({message:String(error)})}) )if (!response.ok){return yield* _(Effect.fail(new NetworkError({message:'Failed to fetch'})))}// データ取得・JSONパースconst data = yield* _( Effect.tryPromise({try: ()=> response.json(),catch: (error)=>new ParseError({message:String(error)})}) )return dataasUser})// 3. エラー処理(処理漏れがあるとコンパイルエラー)const program = fetchUser("123").pipe( Effect.catchTags({NetworkError: (error)=>{console.error(`Network failed:${error.message}`)return Effect.succeed(defaultUser)},ParseError: (error)=>{console.error(`Parse failed:${error.message}`)return Effect.succeed(defaultUser)}// どちらか一方でもコメントアウトすると、コンパイルエラー!}))// 4. 実行const main =async ()=>{const user =await Effect.runPromise(program)console.log(user)}main()
これにより、エラー型がNetworkError | ParseError として明示されることになり、両方のエラーを処理しないとコンパイルエラーになります。
コンパイル時にエラーになるというのが非常に素晴らしいですね。 テストコードを書く(つまりランタイム時に検知する)のではなく、コンパイラの機能を用いて自動検出できるというのは非常に心強いです。
これから、より生成AIを用いてコードを自動で書くことが増えていきますが、プログラムの不備を検出する確率がぐっと上がると、より生成AIを強力に使いこなすことが出来るようになりますね。
次にリソース管理の例を見てみましょう。
import{ Effect}from"effect"// ファイル操作の例const processFileWithEffect = (path:string)=> Effect.acquireRelease(// 確保 Effect.sync(()=>{console.log(`Opening file:${path}`)return{read: ()=>"file contents",close: ()=>console.log("Closing file")}}),// 解放(エラーが起きても必ず実行される) (file)=> Effect.sync(()=> file.close()) ).pipe( Effect.flatMap((file)=> Effect.gen(function* (_){const contents = yield* _(Effect.sync(()=> file.read()))yield* _(Effect.sync(()=>console.log(contents)))return contents}) ) )// 実行Effect.runPromise(processFileWithEffect("data.txt"))
依存性注入(Requirements)
継続の概念からは少し外れますが、Effect System には、しっかり依存性注入の仕組みもあります:
import{ Effect, Context}from"effect"// サービスの定義class Databaseextends Context.Tag("Database")<Database,{readonlyquery: (sql:string)=>Effect.Effect<any[],never>}>(){}// Databaseに依存する関数const getUsers = Effect.gen(function* (_){const db = yield* _(Database)const users = yield* _(db.query("SELECT * FROM users"))return users})// 型: Effect<any[], never, Database>// ^^^^^^^^ 依存が型に現れる// 依存を提供して実行const dbLive = Database.of({query: (sql)=> Effect.succeed([{id:1,name:"Alice"}])})Effect.runPromise( getUsers.pipe(Effect.provide(dbLive)))
これにより、型安全なDIを実現できるため、実行環境によって異なる実装を提供することが容易になります。(テスト環境と本番環境で異なるDBIOを用意するなど)
Effect-TS は TypeScript 上で Effect System を実現する優れたライブラリですが、いくつかの限界があります。
Effect-TS は、ライブラリレベルでの実装であり、言語レベルのサポートではありません。そのため、次のような制約があります:
これらは、TypeScript という言語の上にライブラリとして実装されているための制約です。
これらの課題を根本的に解決するには、言語レベルで Effect System をサポートする必要があります。
そのような言語として、次のようなものがあります:
これらの言語では、Effect が言語の一級市民として扱われ、コンパイラレベルで最適化されるため、Effect-TS のような制約がありません。特にOCamlは5.0 で Effect Handlers という機能が導入されたため、非常に注目株となっています。
本記事では、Effect Systemの理解に必要な「継続 - Continuation」の概念を解説しました。
TypeScriptで扱う例として、Effect-TSでのサンプルコードの例を紹介しましたが、まだプロダクションレベルでの実践には実は至っていません。
というのも、先に述べた課題の面が大きく、プロダクションではまだ扱えないなというのが今のところの所感です。
しかし、今後、生成AIにプログラムを書かせる頻度は爆増し、プログラムの品質をより効率的に担保するにはどうしたら良いか? という議論が出てきた際に、「Effect System」という選択肢は非常に有力だと思っています。
ということで、いつもの流れですが、弊社では技術課題に挑戦する仲間をいつも募集しておりますので、ぜひご応募をお待ちしております!!ここまでご精読ありがとうございました。
引用をストックしました
引用するにはまずログインしてください
引用をストックできませんでした。再度お試しください
限定公開記事のため引用できません。