Effectを使って予測可能なコードを書こう
はじめに
「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
型のオブジェクトは関数オブジェクトのようなものと捉えることができます(厳密には違います)。Success
とError
という名前があらわす通り、実行結果に対して成功と失敗という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.runPromise
はEffect
が内包する計算を実行し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
型の型パラメータSuccess
とFailure
について説明しましたが、最後に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
と呼んでいます。サービスに依存するEffect
はContext.Tag
という、Effect
に依存関係が注入されたときにContext
から実装を取り出すための識別子の役割となるオブジェクトを使って計算を定義します。コードを見てみるとfetchUserById
関数の実装について感知することなくgetUserAttributesUsecase
が実装できていることがなんとなくわかると思います。Contextの詳細については別記事でも解説していますので、そちらも参照ください。
依存関係を解決はEffect.provide
という関数を使ってgetUserAttributesUsecase
にContext
を注入しています。その結果、出来上がったEffect
の第3型パラメータがneverになっていることから依存関係が解決されていることがわかります。
まとめ
本記事では、TypeScriptでの開発においてEffectライブラリがいかに堅牢なアプリケーション構築に貢献するか、その一面を紹介しました。
Effectライブラリが目指すのは、開発者がより安全で、かつ効率的に作業できる環境を提供することだそうです。まだベータ版扱いではあるものの、必要な機能は揃っているように感じます。しかしライブラリの規模に対して、オンボーディングに必要なドキュメントが少なく、また学習コストもそれなりに必要なものに見受けられます。したがってチームでの開発に取り入れるかどうかは慎重な判断が必要だと思います。
しかし、本番環境で必要とされる堅牢さとはどういったものなのか、開発者に刺激を与えてくれるという面では大きな可能性を持っていると個人的には感じます。今後もEffectライブラリに関する解説記事を追加していく予定ですので、具体的な使い方や実践的な例についてはそちらをご覧いただければと思います。
Discussion