🌊

Effectを使って予測可能なコードを書こう

2024/02/22に公開

はじめに

Effect」というライブラリをご存知でしょうか?

Effectライブラリは、TypeScriptを用いて堅牢なアプリケーションを開発するためのツールキットを目指して開発が進められています。本記事ではEffectライブラリがどういった面で堅牢性に寄与するのか、私なりの考えを踏まえて紹介したものです。

なお、本記事ではEffectライブラリの詳細な使い方は説明しません。解説記事は少しずつ追加していく予定です。

Effectライブラリとは

Effect is a powerful TypeScript library designed to help developers easily create complex, synchronous, and asynchronous programs.
Welcome to Effect – Effect Docs

Effectは、開発者が複雑な同期・非同期プログラムを簡単に作成できるように設計された強力なTypeScriptライブラリである。

ドキュメントにも書いてある通り、Effectライブラリは堅牢なアプリケーションの開発をサポートするツールキットです。ScalaやHaskellといった関数型プログラミング言語に影響を受けているそうです。Effectライブラリは関数型プログラミングの哲学の実現よりも、TypeScriptの型システムを活かしてアプリケーションにおける実用的な問題を、堅牢さをもたらしつつ解決することを最も重要視しているそうです。例えば以下のような機能が備わっています。

  • エラーハンドリング
  • Dependency Injection
  • リソースの作成と解放の制御
  • 構造化並行処理(Structured Concurrency)
  • ストリームプログラミング
  • ランタイムに依存しないI/O抽象化
  • ロギング
  • etc...

Effectライブラリが解決する問題

Effectライブラリが言うところの「TypeScriptの型システムを活かした堅牢性」というのがどういうことかを説明します。

例えば以下のようなシグネチャを持つ関数があったとします。

const getUserAttributesUsecase = (userId: string): Promise<User> => { /* ... */ }

この関数のシグネチャからはどういった情報が読み取れるでしょうか。引数と戻り値を見てみると、ユーザIDを渡してUser型のオブジェクトを返してくれそうだということがわかります。また、Promiseで包まれていることから非同期処理であることがわかります。

この関数のシグネチャからわかることはこのくらいだと思います。しかしこの関数を実行したときにはそれ以外の事象が発生することも考えられます。例えば指定したユーザが見つからないといったエラーが発生する可能性が考えられます。また、この関数はどうやってユーザ情報を取得するのでしょうか。データベースに接続するのか、もしくは別のサービスのREST APIを介して取得するのかもしれません。そういった場合、外部サービスとのコネクション確立時にエラーが発生したりするかもしれません。このようにどういったエラーが発生する可能性があるのか、またどんな外部サービスとの依存関係があるかは、この関数の実装を見てみないとわかりません。

EffectライブラリではEffect型を使ってこの問題を解決します。すなわち、シグネチャを見るだけで、その処理によってどんな値が得られるか、どんなエラーが発生する可能性があるのか、またどのような依存関係があるのかを把握できるようになるようEffectライブラリがサポートしてくれます。関数の振る舞いがどういったものか容易に把握できれば、その関数を使うコードも不要な心配をせずに使うことができます。また、コードを読むときにも振る舞いが事前に把握できていれば内容の理解が捗ります。

Effect型

Effect型は3つの型パラメータを持っています。

interface Effect<Success, Failure = never, Requirements = never>

Effect型のオブジェクトは関数オブジェクトのようなものと捉えることができます(厳密には違います)。SuccessErrorという名前があらわす通り、実行結果に対して成功失敗という2つの分類が存在し、どちらかを戻り値として返します。すなわち例外を投げることはありません。型パラメータが表す意味はそれぞれ以下の通りです。

  • Success: 計算結果が成功であるときに戻ってくる値の型。
  • Failure: 計算結果が失敗であるときに戻ってくる値の型。neverが指定されているときは、その計算は失敗することがないことを意味する。
  • Requirements: 計算が依存するデータの型。neverが指定されているときは、その計算は何にも依存しないことを意味する。

Effect型のオブジェクトは関数オブジェクトのようなものと言いましたが、通常の関数評価の書式(=func() のような)を使うことはできません。それではどのように計算を実行するのか、Effect型のオブジェクトの作り方と併せて説明します。

Effectの作成と実行

Effect型のオブジェクト(以下、Effectとします)を作るための関数が多く用意されています。その中でもよく使うものとして、既存の関数を使ってEffectを作るものがあります。以下は失敗する可能性がある非同期関数からEffectを作るため、Effect.tryPromiseという関数を使う例です。

type Todo = {  
  userId: number  
  id: number  
  title: string  
  completed: boolean  
}

const fetchTodo = (id: number): Effect.Effect<Todo, Error> =>  
  Effect.tryPromise({  
   try: async () => {  
    const result = await fetch(`https://jsonplaceholder.typicode.com/todos/${id}`)  
  
    if (result.ok) {  
     return (await result.json()) as Todo  
    }  
  
    // 2xx以外のステータス出会った場合はステータスコードをスローする
    throw result.status  
   },  
   catch: reason => {  
    if (typeof reason === 'number') {  
     return new Error(`Status: ${reason}`)  
    }  
    // ネットワークエラーなど  
    return new Error(`Unknown error: ${reason}`)  
   },  
  })

IDを指定してTodoを取得する計算を定義することができました。しかし、このfetchTodoを以下のように呼び出したとしても定義した計算は実行されません

fetchTodo(1)

fetchTodoの戻り値はEffectを返します。Effect関数オブジェクトのようなものなので、fetchTodoは「計算(=Effect)を定義するだけ」の関数といえます。関数型プログラミングを使ったことがある方なら、Effectはサンクのようなものと言ったほうが理解しやすいかもしれません。

Effectが内包する計算を実際に実行するためには、Effectライブラリが提供する「Effect実行関数」を使う必要があります。

await Effect.runPromise(fetchTodo(1))  
  .then(console.log)  
  .catch(console.error)

Effect.runPromiseEffectが内包する計算を実行しPromiseを返します。このPromiseがResolveされたときに成功した計算の結果を取得することができます。

エラーハンドリング

先ほどの例ではEffectの結果が失敗したときは、runPromiseが返すPromiseがRejectされます。すなわちcatchに渡したコールバックが呼び出されますが、コールバックに渡される値はunknown型となります。せっかくEffect型が計算が失敗したときの値の型を明示してくれたとしても、Effectを使う側でそれが推論されないのであれば意味がありません。EffectライブラリではTypeScriptの型システムを活かしたエラーハンドリングのパターンをいくつか用意しています。その一つを紹介するため、まずは先ほどのfetchTodo関数を修正します。

import { Effect, Data } from 'effect'

class NotFoundTodoError {  
  _tag = 'NotFoundTodoError' as const  
  constructor(readonly id: number) {}  
}  
class InvalidRequestError {  
  _tag = 'InvalidRequestError' as const  
  constructor(readonly message: string) {}  
}  
class NetworkError {  
  _tag = 'NetworkError' as const  
}
  
const fetchTodo = (  
  id: number,  
): Effect.Effect<Todo, NotFoundTodoError | InvalidRequestError | NetworkError, never> =>  
  Effect.tryPromise({  
   try: async () => {  
    const result = await fetch(`https://jsonplaceholder.typicode.com/todos/${id}`)  
  
    if (result.ok) {  
     return (await result.json()) as Todo  
    }  
  
    throw result.status === 404  
     ? new NotFoundTodoError({ id })  
     : new InvalidRequestError({ message: result.statusText })  
   },  
   catch: reason => {  
    if (reason instanceof NotFoundTodoError || reason instanceof InvalidRequestError) {  
     return reason  
    }  
  
    return new NetworkError()  
   },  
  })  

発生しうる失敗の種類ごとに型を作成し、fetchの実行結果に応じてその型のインスタンスを返すようにしました。(失敗の型についてはError等を継承して作るとよりよいですが、説明のため簡単な作りにしています)

次に、このEffectにエラーハンドリングの処理を追加します。

const handledEffect: Effect.Effect<void, never> = pipe(  
  fetchTodo(1),        // Effect.Effect<Todo, NotFoundTodoError | InvalidRequestError | NetworkError>
  Effect.catchTags({   // ①  
   NotFoundTodoError: ({ id }) => Console.error(`Todo ${id} not found`),  
   InvalidRequestError: ({ message }) => Console.error(message),  
   NetworkError: () => Console.error('Network error'),  
  }),  
  Effect.flatMap(todo => Console.log(todo)),
)
  
await Effect.runPromise(handledEffect)

エラーハンドリングの実装自体は標準出力にメッセージを表示する単純なものですが、このコードで重要なのは①のEffect.catchTagsです。これは前段のEffectによる処理が失敗したときに何をするかを定義しています。さらにEffectのエラー部の型である、タグ付きユニオンを判別して各エラー型ごとに処理を定義しています。これらはすべてTypeScriptによってエラー型がどういったものか推論されており、例えばNetwokrError: ...についてのエラーハンドリングが欠けている、すなわち発生しうるエラーに対してのエラーハンドリングに漏れがある場合にはコンパイルエラーが発生します。このようにEffectライブラリで提供されているモジュールを使うことによって、エラーも型付けされた世界を実現することができ、異常系もコントロールしやすい堅牢なコードを書くことが可能となります。

Dependency InjectionとContext

ここまででEffect型の型パラメータSuccessFailureについて説明しましたが、最後に3つめの型パラメータRequirementsについて説明します。

冒頭で使った例をもう一度使います。

const getUserAttributesUsecase = (userId: string): Promise<User> => { /* ... */ }

この関数は何かしらの外部サービスを使ってユーザ情報を取得し、それを加工して戻り値として返すものだとしましょう。このときよく使われる設計パターンとして、getUserAttributesUsecaseは外部サービスの実装についての知識は持たなくても済むよう、外部サービスのインタフェースに対してプログラミングをすれば機能が実現できるような構造を作ると言うものがあります(いわゆるDependency Inversion Principleというもの)。Effectライブラリでも(TypeScriptのinterfaceよりも広義の意味での)インタフェースに対してプログラミングする方法と、そのインタフェースの実装の提供部を分離(Dependency Injection)してコードを書く方法を提供しています。

では、まずはgetUserAttributesUsecaseのシグネチャをEffectを使って書き換えます。

type User = {  
  userId: string  
  name: string  
}  
  
class NotFoundUserError extends Data.TaggedError('NotFoundUserError')<{ userId: string }> {}  

// 外部サービスのインタフェース
type FetchUserService = {  
  fetchUserById: (userId: string) => Effect.Effect<User, NotFoundUserError>  
}  
  
const getUserAttributesUsecase = (userId: string): Effect.Effect<User, NotFoundUserError, FetchUserService> => {}

getUserAttributesUsecaseが返すEffect型がEffect.Effect<User, NotFoundUserError, FetchUserService>となっています。これはこのEffectが内包する計算は、外部サービスからUser情報を取得する関数を持ったFetchUserService型に依存していることを表しています。

別のサービスに依存したEffectを作るためには、EffectライブラリのContextモジュールで公開されている関数を使います。

// Effect内部で使われている依存サービスのインタフェースと、あとで注入されるサービスの実装とを紐づけるためのオブジェクト
const FetchUserService = Context.GenericTag<FetchUserService>('FetchUserService')

// 外部サービスに依存したユースケース関数  
const getUserAttributesUsecase = (  
  userId: string,  
): Effect.Effect<User, NotFoundUserError, FetchUserService> =>  
  pipe(  
   FetchUserService,  // 依存するサービス
   Effect.flatMap(({ fetchUserById }) => fetchUserById(userId)),  
   // 追加でユースケース実現のための処理があれば続けて書くこともできる
  )  
  
// 外部サービスの実装  
const FetchUserServiceContext = Context.make(FetchUserService, {  
  fetchUserById: (userId: string) =>  
   Effect.tryPromise({  
    try: async () => {  
     const response = await fetch(`https://exapmle.com/users/${userId}`)  
     const user = (await response.json()) as User  
  
     return user  
    },  
    catch: () => {  
     return new NotFoundUserError(userId)  
    },  
   }),  
})  
  
// 依存関係の解決  
const dependencyResolvedEffect =  
  (userId: string): Effect.Effect<User, NotFoundUserError, never> =>  
  getUserAttributesUsecase(userId)  
   // Effect.provide関数で依存関係の注入  
   .pipe(Effect.provide(FetchUserServiceContext))  
  
const result = await Effect.runPromise(dependencyResolvedEffect('user-id'))

Effectライブラリではサービスのインタフェースとその実装の組を保持する機能をContextと呼んでいます。サービスに依存するEffectContext.Tagという、Effectに依存関係が注入されたときにContextから実装を取り出すための識別子の役割となるオブジェクトを使って計算を定義します。コードを見てみるとfetchUserById関数の実装について感知することなくgetUserAttributesUsecaseが実装できていることがなんとなくわかると思います。Contextの詳細については別記事でも解説していますので、そちらも参照ください。

依存関係を解決はEffect.provideという関数を使ってgetUserAttributesUsecaseContextを注入しています。その結果、出来上がったEffectの第3型パラメータがneverになっていることから依存関係が解決されていることがわかります。

まとめ

本記事では、TypeScriptでの開発においてEffectライブラリがいかに堅牢なアプリケーション構築に貢献するか、その一面を紹介しました。

Effectライブラリが目指すのは、開発者がより安全で、かつ効率的に作業できる環境を提供することだそうです。まだベータ版扱いではあるものの、必要な機能は揃っているように感じます。しかしライブラリの規模に対して、オンボーディングに必要なドキュメントが少なく、また学習コストもそれなりに必要なものに見受けられます。したがってチームでの開発に取り入れるかどうかは慎重な判断が必要だと思います。

しかし、本番環境で必要とされる堅牢さとはどういったものなのか、開発者に刺激を与えてくれるという面では大きな可能性を持っていると個人的には感じます。今後もEffectライブラリに関する解説記事を追加していく予定ですので、具体的な使い方や実践的な例についてはそちらをご覧いただければと思います。

参考

Effect – The best way to build robust apps in TypeScript

Discussion