Next.jsでEffectを使う
Effectについて
[Effect(https://effect.website)はType SafetyやDIの仕組みをFunctional Programingスタイルで実現したrobustなコードが書けるライブラリです。Domain Modeilng Made FunctionalのRailway Styleだったりカリー化によるDIのような書き方ができて良さそうということで、学習を進めています。
Effectと他フレームワークとの連携
みんな大好きNext.jsで使いたいと思ったのですが、公式ドキュメントではフレームワークとの連携例はExpressによる例があるのみです。
また、Effectの使い方としては、安心安全なEffect圏でコードをたくさん書いて、プログラムのエントリポイントなどの"外の世界"からはそれを丸ごと実行するだけのような使い方のサンプルが多いです。ですが実際にはルーティングなどはフレームワーク側が担っており、フレームワーク側が処理の起点になる場合でのおすすめの書き方がわかりにくいところがありました。
Remixとの連携例が公開された
しかし、Effectの作者の一人であるMichael ArnaldiさんによるRemixと組み合わせて使う動画がEffect公式チャンネルで2024/05/13に公開されていて、そこで使い方がわかりました。ありがたや…
この動画では、Effectのドキュメントには書いてないような、Version 2.0で入った新し目の書き方なども使っておりEffectユーザーは必見です。
Next.jsとも連携できた
この動画および概要欄にあるリポジトリを参考にして、Next.jsとEffectの連携をスッキリ書くことができたので掲載します。
EffectのLayerによるRepository/Service層の準備
まず、呼び出したいサービスクラスやリポジトリクラスを、Layerとして書きます。このあたりは普通のEffectの世界なので、公式ドキュメント通りに書けばOKですが、上記の動画の方でもっとすっきりした書き方をされていたので、真似しています。
ここではDBへのアクセスをするUserRepositoryと、それを呼び出すUserServiceを定義してみます。
内部でsqlcが生成したコードを使ってますが、関係ないので適宜読み替えてください。
UserRepository
import { Client } from '@/db/client'
import { type GetUserArgs, getUser } from '@/gen/sqlc/pg/users_sql'
import { Context, Effect, Layer } from 'effect'
const makeUserRepository = Effect.andThen(Client, (client) => {
return {
getUser: (args: GetUserArgs) => {
return Effect.tryPromise({
try: () => getUser(client, args),
catch: (e) =>
new UserRepositoryError(
e instanceof Error ? e : new Error('failed to get user: ${e}'),
),
})
},
}
})
// biome-ignore lint/complexity/noStaticOnlyClass: <explanation>
export class UserRepository extends Context.Tag('UserRepository')<
UserRepository,
Effect.Effect.Success<typeof makeUserRepository>
>() {
static Live = Layer.effect(this, makeUserRepository)
}
export class UserRepositoryError {
readonly _tag = 'UserRepositoryError'
error: Error
constructor(e: Error) {
this.error = e
}
}
UserService
import { UserRepository } from '@/repository/user'
import type { UserId } from '@/schema/user'
import { Data, Effect, Layer } from 'effect'
class UserNotFound extends Data.TaggedError('UserNotFound')<{
message: string
}> {}
const makeUserService = Effect.gen(function* () {
const userRepository = yield* UserRepository
const workspaceRepository = yield* WorkspaceRepository
return {
getUser: (userId: UserId) =>
Effect.gen(function* () {
const user = yield* userRepository.getUser({ id: userId })
if (user == null) {
return yield* new UserNotFound({
message: `Usert not found for ${userId}`,
})
}
return user
}),
}
})
// biome-ignore lint/complexity/noStaticOnlyClass: <explanation>
export class UserService extends Effect.Tag('UserService')<
UserService,
Effect.Effect.Success<typeof makeUserService>
>() {
static Live = Layer.effect(this, makeUserService)
}
Next.jsの世界とEffectの世界を繋げる
今回はServerside Componentから上記のクラスを呼び出し、ユーザーを取得します。
そのために、Service層をNext.jsの世界に公開し、Effectの世界を繋げるutilsを書きます。私のコードベースではこれをservice/index.jsにおいて、Effectで書いたすべてのServiceを公開するようにしています。
service/index.ts
import { Client } from '@/db/client'
import { UserRepository } from '@/repository/user'
import { UserService } from '@/service/user'
import { type Effect, Either, Layer, ManagedRuntime } from 'effect'
// 1
export const Live = Layer.mergeAll(
UserService.Live.pipe(
Layer.provide(Layer.merge(UserRepository.Live, AnotherRepository.Live)),
Layer.provide(Client.Live),
),
AnotherService, ...
)
// 2
export const makeNextRuntime = <R, E>(layer: Layer.Layer<R, E, never>) => {
const runtime = ManagedRuntime.make(layer)
const run = <A, E>(
body: () => Effect.Effect<A, E, R>,
): Promise<Either.Either<A, E>> =>
runtime
.runPromise(body())
.then((a) => Either.right(a))
.catch((e) => Either.left(e as E))
return { run }
}
// 3
export const { run: runService } = makeNextRuntime(Live)
// 1
まず、Layerの仕組みを使い、各Layerに依存を注入して組み立てています。Layer.mergeAllで、今後増えていくサービスをすべて提供します。
// 2
makeNextRuntime
と名付けた部分が、実際にNext.jsのRSCやAPIから呼び出すコードです。ここで鍵となるのは上記の動画で利用されていた(ManagedRuntime)[https://effect-ts.github.io/effect/effect/ManagedRuntime.ts.html] です。Runtimeもいくつか種類があるので、この部分で悩んでいたのですが、ManagedRuntime
を使えば良いことがわかりました(動画の18分~くらい)
ここで、mergeAllで作ったlayerを// 3の部分で渡すことでManagedRuntimeを作り、runtime.runPromiseでEffectを動かし通常のJavaScriptのPromiseとして公開しています。
ただし、RSC側でPromiseのcatch節を書きたくない(せっかく型でエラーを表現しているのに意味がなくなる)ので、Eitherに変換しています。
実際に呼び出す
あとは、RSCやAPIからこれを呼び出すだけです。今回はAuth.jsのセッションからユーザーIDを取得して、Effect側のUserServiceに問い合わせる処理を書いています。
WelcomeScreen.tsx
import { auth } from '@/auth'
import { UserId } from '@/schema/user'
import { runService } from '@/service'
import { UserService } from '@/service/user'
import { Either } from 'effect'
export async function MainScreen() {
const session = await auth()
if (!session?.user?.id) {
throw Error('authentication failed')
}
const user = session.user
const userId = session.user.id
const userOrError = await runService(() =>
UserService.getUser(UserId(userId)),
)
return Either.isRight(userOrError) ? (
userOrError.right ? (
<SomethingComponentThatUsesUser user={userOrError} />
) : (
// TODO: Errorに応じてメッセージを出しわける。
<div>Something went wrong!: {userOrError.left.toString()}</div>
)
}
これで完成です。動画中でも書いてありましたがUserService.getUserをクリックするとちゃんと定義部に飛べるのも最高。もっといい書き方があればぜひ教えてください。
Happy Effect Coding!
Discussion