🦾

Next.jsでEffectを使う

2024/05/13に公開

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