Upgrade to Pro — share decks privately, control downloads, hide ads and more …

Real World Effect-TS: 堅牢なプロダクトを型で組み上げる

Avatar for asa1984 asa1984
May 23, 2026
280

Real World Effect-TS: 堅牢なプロダクトを型で組み上げる

TSKaigi 2026 登壇資料

https://2026.tskaigi.org/talks/50

Avatar for asa1984

asa1984

May 23, 2026

Transcript

  1. 今日話すこと 1. TS バックエンドの課題 Why: なぜ Effect-TS が欲しくなるか 2. Effect-TS

    を使う How: どうアプリケーションに組み込むか What: 何が嬉しいか 3. トレードオフと判断 学習コスト・高い侵襲性・採用を見送った例 TSKaigi 2026 asa1984 / HERP, Inc. 2 / 77
  2. 要点 型システムの力を最大限活かす設計 1. 型でドメインモデリング 型でドメインオブジェクトを作る 関数で状態遷移させる 3. 副作用を分離する ドメインロジックを純粋に保つ 2.

    Errors as Values エラーを値として扱う 失敗を型シグネチャに乗せる 4. ワークフローに合成する 各ステップを上手いこと合成して見通 しをよくする TSKaigi 2026 asa1984 / HERP, Inc. 9 / 77
  3. 課題 2: 言語機能・制約と合意 後発の静的型付け言語は 軽量なライブラリの組み合わせ で書きがち: Go, Rust, etc… しかし、TS

    には これらの言語ほど言語機能・制約・合意がない: 型システムの表現力はあるのだが... JavaScript の仕様にキャップされる 自由度ゆえの無数の実装パターンと車輪の再発明 最たる例: エラーハンドリングライブラリの乱立 TSKaigi 2026 asa1984 / HERP, Inc. 11 / 77
  4. Effect-TS という選択肢 Welcome to Effect, a powerful TypeScript framework that

    provides a fully- fledged functional effect system with a rich standard library. — Effect-TS/effect README.md より 「TypeScript」のフレームワーク アプリケーション開発を行うために必要な道具をオールインワンで提供する エラーハンドリング DI 非同期処理 Observability ストリーム バリデーション データ型周りのユーティリティー etc... TSKaigi 2026 asa1984 / HERP, Inc. 12 / 77
  5. そもそも Effect-TS ってなに? npm パッケージ名は effect 「Effect」だと一般名詞すぎて紛らわしいので、Effect-TS と呼ばれることが多い 大きく見ると 2

    つの側面がある: ユーティリティ集 Option , Stream , Schema , … 標準ライブラリ的な型と関数群 類縁: fp-ts, neverthrow など Effect Runtime Effect<A, E, R> 型を中心とした プログラム実行系 唯一無二 TSKaigi 2026 asa1984 / HERP, Inc. 20 / 77
  6. Effect<A, E, R> Effect<A, E, R>; // │ │ └─

    依存(環境) // │ └──────── 起こりうるエラーの型 // └───────────────── 成功時の値の型 Promise<T> は T しか型に乗らないが、Effect は 失敗 と 依存 も型に乗せる。 TSKaigi 2026 asa1984 / HERP, Inc. 22 / 77
  7. Promise と Effect const fetchUser: () => Promise<User>; const fetchPosts:

    (userId: UserID) => Promise<Post[]>; const main = async () => { const user = await fetchUser(); const posts = await fetchPosts(user.id); return { user, posts }; }; await main(); import { Effect } from 'effect'; const fetchUser: () => Effect.Effect<User>; const fetchPosts: (userId: UserID) => Effect.Effect<Post[]>; const main = Effect.gen(function* () { const user = yield* fetchUser(); const posts = yield* fetchPosts(user.id); return { user, posts }; }); await Effect.runPromise(main); TSKaigi 2026 asa1984 / HERP, Inc. 26 / 77
  8. Effect.gen() Promise に対する then と async / await Effect に対する

    pipe のチェーンと Effect.gen / yield* TSKaigi 2026 asa1984 / HERP, Inc. 27 / 77
  9. then const fetchUser: () => Promise<User>; const fetchPosts: (userId: UserID)

    => Promise<Post[]>; fetchUser().then((user) => fetchPosts(user.id)); pipe と Effect.flatMap import { Effect } from 'effect'; const fetchUser: () => Effect.Effect<User>; const fetchPosts: (userId: UserID) => Effect.Effect<Post[]>; Effect.runPromise( fetchUser().pipe(Effect.flatMap((user) => fetchPosts(user.id))), ); TSKaigi 2026 asa1984 / HERP, Inc. 28 / 77
  10. コールバック地獄回避 const fetchUser: () => Promise<User>; const fetchPosts: (userId: UserID)

    => Promise<Post[]>; const main = async () => { const user = await fetchUser(); const posts = await fetchPosts(user.id); return { user, posts }; }; await main(); フラットに書く import { Effect } from 'effect'; const fetchUser: () => Effect.Effect<User>; const fetchPosts: (userId: UserID) => Effect.Effect<Post[]>; const main = Effect.gen(function* () { const user = yield* fetchUser(); const posts = yield* fetchPosts(user.id); return { user, posts }; }); await Effect.runPromise(main); TSKaigi 2026 asa1984 / HERP, Inc. 29 / 77
  11. Effect.gen() Effect は pipe で繋ぐこともできるが、Effect.gen() を使うと手続き的構文で書ける: const program = Effect.gen(function*

    () { const user = yield* fetchUser(id); const posts = yield* fetchPosts(user.id); return { user, posts }; }); Promise に対する then と async / await Effect に対する pipe のチェーンと Effect.gen / yield* ユーザーは「async/await っぽい何か」と思って書けばよい。 TSKaigi 2026 asa1984 / HERP, Inc. 30 / 77
  12. なぜジェネレーター? ジェネレーターは イテレーターの生成に使う機能 処理の中断・再開の表現に適している yield : 値を呼び出し元に返して、その場で処理を一時中断する yield* : 別のジェネレーター/イテラブルに処理を委譲する

    イテレーターが flatten される async/await が無い時代は、ジェネレーターを使って async/await 相当を実現する ライブラリがあった(co など) ES2015: Promise, ジェネレーター ES2017: async/await TSKaigi 2026 asa1984 / HERP, Inc. 31 / 77
  13. 伝播・合成 import { Data, Effect } from 'effect'; class UserNotFound

    extends Data.TaggedError('UserNotFound')<{ id: string }> {} class PostsForbidden extends Data.TaggedError('PostsForbidden')<{ id: string }> {} const fetchUser: () => Effect.Effect<User, UserNotFound>; const fetchPosts: (userId: UserID) => Effect.Effect<Post[], PostsForbidden>; const program = Effect.gen(function* () { const user = yield* fetchUser(); const posts = yield* fetchPosts(user.id); return { user, posts }; }); // program: Effect<{ user: User; posts: Post[] }, UserNotFound | PostsForbidden> yield* するだけで エラー型は E チャネルに union として合成される TSKaigi 2026 asa1984 / HERP, Inc. 34 / 77
  14. TaggedError import { Data } from 'effect'; class UserNotFoundError extends

    Data.TaggedError('UserNotFoundError')<{ readonly userId: string; }> {} Discriminated union (Effect-TS は discriminator のキーを _tag に統一している) Error を継承している Tips: Error はインスタンス化時に stack trace をキャプチャするので、エラー 表現物として最適 TSKaigi 2026 asa1984 / HERP, Inc. 35 / 77
  15. 実例: GraphQL resolver でのエラーハンドリング // ユーザーを招待する mutation の GraphQL resolver

    const inviteUser: MutationResolvers<Context>['inviteUser'] = authenticated((_, { input }, context) => Effect.gen(function* () { /* 中略 */ const invitation = yield* inviteUserUseCase({ emailAddress: input.emailAddress, inviterName: context.user.name, organization: context.organization, }); return { __typename: 'InviteUserOutput' as const, invitation, }; }).pipe( /* 次ページへ */ ), ); TSKaigi 2026 asa1984 / HERP, Inc. 37 / 77
  16. Effect.gen(function* () { /* 中略 */ }).pipe( Effect.catchTags({ // GraphQL

    の union の variant にして返す '@argent/use-cases/errors/InvitationAlreadyExists': (e) => Effect.succeed({ __typename: 'InvitationAlreadyExists' as const, invitation: e.invitation, }), // GraphQLError に変換 '@argent/use-cases/errors/TooManyExecutions': () => Effect.fail(new TooManyRequests()), // ... }), ), Tips: TaggedError のタグはモジュールのパス + シンボル名にすると一意になるので楽 TSKaigi 2026 asa1984 / HERP, Inc. 38 / 77
  17. 比較: 素朴な条件分岐 type Result<T, E> = { _tag: 'ok'; value:

    T } | { _tag: 'err'; error: E }; const fetchUser: () => Result<User, UserNotFound>; const fetchPosts: (userId: UserID) => Result<Post[], PostsForbidden>; const main = (): Result<{ user: User; posts: Post[] }, UserNotFound | PostsForbidden> => { const userResult = fetchUser(); if (userResult._tag === 'err') return userResult; const postsResult = fetchPosts(userResult.value.id); if (postsResult._tag === 'err') return postsResult; return { _tag: 'ok', value: { user: userResult.value, posts: postsResult.value }, }; }; TSKaigi 2026 asa1984 / HERP, Inc. 39 / 77
  18. 比較: neverthrow import { Result } from 'neverthrow'; const fetchUser:

    () => Result<User, UserNotFound>; const fetchPosts: (userId: UserID) => Result<Post[], PostsForbidden>; const main = (): Result< { user: User; posts: Post[] }, UserNotFound | PostsForbidden > => fetchUser().andThen((user) => fetchPosts(user.id).map((posts) => ({ user, posts })), ); yield* するだけに比べると慣れが必要 TSKaigi 2026 asa1984 / HERP, Inc. 40 / 77
  19. 比較: neverthrow ( safeTry ) neverthrow にも ジェネレーター記法 が用意されている (safeTry

    ): import { ok, safeTry, type Result } from 'neverthrow'; const main = (): Result< { user: User; posts: Post[] }, UserNotFound | PostsForbidden > => safeTry(function* () { const user = yield* fetchUser(); const posts = yield* fetchPosts(user.id); return ok({ user, posts }); }); TSKaigi 2026 asa1984 / HERP, Inc. 41 / 77
  20. 差分 safeTry で errors-as-values 部分は揃えられるが、Effect-TS が提供するのはそれだけ ではない(後述): DI ( R

    チャネル) 非同期ランタイム / Structured Concurrency Observability → 逆にエラーハンドリング単体が欲しい場合は neverthrow でよい TSKaigi 2026 asa1984 / HERP, Inc. 42 / 77
  21. 寄り道: Two Types of Errors Q. 結局ライブラリなどから throw されるなら errors-as-values

    って無駄では? A. ドメインエラー (expected) を型で表現可能にすることに価値がある TSKaigi 2026 asa1984 / HERP, Inc. 44 / 77
  22. 寄り道: Two Types of Errors Effect-TS は 2 種類の失敗 を区別する思想:

    Expected ドメインエラー 例: 400 番台で返したいエラー Effect.fail や TaggedError で表現 型 E に乗せる Unexpected (defect) インフラエラーなど 例: 500 番台に潰してもいいエラー Effect.die / throw 由来 型に乗せない TSKaigi 2026 asa1984 / HERP, Inc. 45 / 77
  23. 実例: Effect.die でエラー型を潰す コンパイラより人間が賢いケースで、開発者の判断でエラーを握り潰す。 die させたエラーは E チャネルから消える // 実際のコード:

    とある usecase でエラーを握り潰している例 Effect.gen(function* () { /* 中略 */ }).pipe( Effect.catchTags({ // StillActiveエラーは transaction を用いており起こり得ない事象のため、発生した場合は die として扱う '@argent/domain/EntrustedRecommendation/errors/StillActive': () => Effect.die(recommendation), // ... }), ); TSKaigi 2026 asa1984 / HERP, Inc. 46 / 77
  24. 例外と上手く付き合う 絶対に全て errors-as-values にする必要があるわけではない そのエラーを型で表して嬉しいことは何か throw 絶対 NG ではない 一方で、ちゃんとログに出して例外を検知可能にする

    こと 運用・監視の面でカバーする プログラムの各層のエントリーポイントで catch しておく Effect.runPromiseExit() HTTP ルーターのミドルウェア サーバーのブートストラップ process.on() TSKaigi 2026 asa1984 / HERP, Inc. 47 / 77
  25. DI(おさらい) 依存(DB、外部 API、ロガー、…)を外から差し込むこと 副作用をドメインロジックから分離して純粋にする Testability の向上 代表的な手法: 明示的な DI 高階関数

    DI — 関数の引数で渡す コンストラクタ注入 — クラスのコンストラクタで渡す DI コンテナ — フレームワークが解決して差し込む TSKaigi 2026 asa1984 / HERP, Inc. 49 / 77
  26. 手法 1: 高階関数 DI const findUser = (db: Db, logger:

    Logger) => async (id: UserID): Promise<User> => { logger.info(`getUser: ${id}`); const row = await db.query('SELECT ...', [id]); return parseUser(row); }; シンプル、依存が引数で明示 ただし、ネストしていくと依存のバケツリレーが発生する 変更範囲の拡大 TSKaigi 2026 asa1984 / HERP, Inc. 50 / 77
  27. 手法 3: Effect Effect<A, E, R> の R R はその

    Effect が要求する「依存 (Requirements)」を表す Effect.run* 系の関数は R が never になっていないと型エラーを発生させる const program: Effect<void, never, Logger | Database>; Effect.runPromise(program); // program の R が never になっていないので型エラー! TSKaigi 2026 asa1984 / HERP, Inc. 52 / 77
  28. Tag と Service Tag が識別子 + インターフェース、Service が実装に相当する Tag を

    yield* して Service を得る Service を注入すると、それより下流のスコープで利用可能になる import { Context, Effect } from 'effect'; // Tag を定義する class Logger extends Context.Tag('Logger')< Logger, { log: (message: string) => void; } >() {} // Effect.Effect<void, never, Logger> const program = Effect.gen(function* () { const logger = yield* Logger; logger.log('Hello, World!'); }); // Logger の実装を注入する const runnable = Effect.provideService(program, Logger, { log: (message: string) => { console.log(message); }, }); await Effect.runPromise(runnable); TSKaigi 2026 asa1984 / HERP, Inc. 53 / 77
  29. Requirements の伝播 エラーと同様、依存を使うと R は 自動で union として合成される: const program

    = Effect.gen(function* () { const user = yield* getUser('u_1'); // R: Db const posts = yield* getPosts(user.id); // R: Db | HttpClient yield* log(`fetched ${posts.length}`); // R: Db | HttpClient | Logger }); // program: Effect<void, ..., Db | HttpClient | Logger> Effect にService を注入すると対応する Tag が R から消える R を never にするまで program は 実行できない 「依存解決し忘れ」がコンパイルエラー TSKaigi 2026 asa1984 / HERP, Inc. 54 / 77
  30. ネストの深い箇所の依存を差し替える resolver(db, logger, mailer) ← uses: logger │ ▼ (db,

    mailer を引き回し) usecase(db, mailer) ← uses: mailer │ ▼ (db を引き回し) repository(db) ← uses: db │ ▼ db.query(...) 末端の依存が増減するたびに、間のすべての関数のシグネチャを変更する必要がある Effect では provide の位置 = 構文上のスコープと一致するため、中間層に手を入れ ずに済む TSKaigi 2026 asa1984 / HERP, Inc. 55 / 77
  31. 実例: Layer による依存関係表現 依存が別の何かに依存しているケースが多い 例: リポジトリが DB クライアントやロガーに依存している Layer<Rout, _,

    Rin> 依存グラフを型で表現する Rout : 提供したいインターフェース 例: リポジトリの Tag Rin : それに必要な別の依存 例: DB クライアント / ロガーの Tag TSKaigi 2026 asa1984 / HERP, Inc. 56 / 77
  32. 右の例は User.Repository を構築するため に以下に依存している: PrismaClientTag : Prisma (DB) OrganizationTag :

    テナント分離用の情 報 (マルチテナント SaaS なので) PrismaClient と Organization が先に依 存注入されていない状態で、この Layer を 使おうとすると 型エラー // Layer<User.Repository, never, PrismaClient | Organization> const UserRepository = Layer.effect( User.Repository, Effect.gen(function* () { const prisma = yield* PrismaClientTag; const organization = yield* OrganizationTag; const find = ( id: User.ID, ): Effect.Effect<User.Entity.BirthRegistered | null> => Effect.promise(async () => { /* Prisma による DB クエリ */ }); /* 中略 */ return { find, // ... }; }), ); TSKaigi 2026 asa1984 / HERP, Inc. 57 / 77
  33. 実例: Effect-TS と Vitest import { describe, expect, it }

    from '@effect/vitest'; import { Effect } from 'effect'; import { tests } from '@argent/tests'; import { findUser } from './findUser.js'; describe(findUser, () => { it.effect('ユーザーが存在する場合、そのユーザーを返す', () => Effect.gen(function* () { const user = yield* tests.fixtures.user.birthRegistered; const { id } = yield* findUser({ id: user.id }); expect(id).toBe(user.id); }).pipe(Effect.provide(tests.layer)), ); }); TSKaigi 2026 asa1984 / HERP, Inc. 58 / 77
  34. @effect/vitest Effect-TS には様々なライブラリとの統合パッケージがある it.effect に Effect を直接渡せる Live な Layer(実際の実装)を

    Test Layer に差し替えてテストする const UserRepository: Layer.Layer<User.Repository> = Layer.effect( User.Repository, Effect.gen(function* () { /* インメモリ実装のフェイク */ }), ); // 同じインターフェースを提供するが、Layer<Rout, never, Rin> の Rin が空のため、 // テストでは PrismaClient, Organization の依存も含めてモックする必要がない TSKaigi 2026 asa1984 / HERP, Inc. 59 / 77
  35. 詳しく知る: Fiber Scheduler Effect は内部的には Fiber という処理単位で表現・実行されている。 グリーンスレッド的存在 React の並行レンダリングにおける

    Fiber と概念的に同じ なぜ、わざわざ独自のスケジューラーを作るのか? Promise より高度な制御を型システムと統合しつつ行うため エラーハンドリング Structured Concurrency 実行文脈情報の保持 DI、構造化ログの属性伝搬、OpenTelemetry とのビルトイン統合 イメージ: 高級な AsyncLocalStorage TSKaigi 2026 asa1984 / HERP, Inc. 61 / 77
  36. Structured Concurrency 子 Fiber の生存期間は親 Fiber に束縛される: 親が完了/中断 → 子の

    Fiber も自動キャンセル 子の失敗 → 兄弟の Fiber も自動キャンセル リソースリークを防ぐ TSKaigi 2026 asa1984 / HERP, Inc. 62 / 77
  37. 比較: AbortController JS 標準のキャンセル機構は AbortController / AbortSignal : const ac

    = new AbortController(); const res = await fetch(url, { signal: ac.signal }); ac.abort(); // 明示的に伝える signal を手でバケツリレーする必要がある 受け取った関数が signal を見ない/渡さないと、内部の処理は止まらない 並行起動した複数 task をまとめて落とすのも自分で組み立てる Effect は Fiber スケジューラがキャンセルを自動伝播 する。 TSKaigi 2026 asa1984 / HERP, Inc. 63 / 77
  38. 学習コスト 初見の感想: Effect.gen() がキモい generator 周りは「あ〜 async/await ね」と腑に落ちれば違和感はなくなる ドキュメントは充実している 網羅的な公式ドキュメントと

    JSDoc 体系的な非公式ドキュメントもある: effect.solutions など 「関数型プログラミング」の取っ付きにくさを最大限排除しようとしている fp-ts との最大の差分 急にモナドの話をしない マーケティング配慮 TSKaigi 2026 asa1984 / HERP, Inc. 68 / 77
  39. 学習コスト 大量のモジュール/API オールインワンなだけあって迷うことがある 解決したい課題に合わせて選ぶ エラーハンドリング・DI のために Effect を使って、それ以外は触らない、 など Effect.andThen

    などの関数の使用を禁止し、原則 Effect.gen() を使う規約 「関数型プログラミング」と呼ばれる概念総体のうち、汎用的な・基本的な考え方の 部分は要る チームメンバーの興味関心と教育 TSKaigi 2026 asa1984 / HERP, Inc. 69 / 77
  40. コミュニティ・エコシステム やはりまだプロダクションへの採用事例は少ない 右肩上がりの npm downloads v4 beta の開発が進行中 Effect-TS オンリーのカンファレンス

    OSS でのサポート・採用 Drizzle ORM v1 での公式サポート告知 opencode (コーディングエージェント) が Effect-TS に移行中 TSKaigi 2026 asa1984 / HERP, Inc. 70 / 77
  41. 採用を見送った例: HERP AI Recruiter HERP の別プロダクト HERP AI Recruiter では、

    一度 Effect-TS の導入を 検討した が、最終的に 見送った なぜ? fp-ts の教訓 Effect-TS の前身でもあるライブラリ Effect-TS 同様、侵襲性が高く負債化、社内で非推奨化された 当時(2025年Q4)のコーディングエージェントの水準 Effect-TS と心中するプロダクトを増やしていいのか? TSKaigi 2026 asa1984 / HERP, Inc. 72 / 77
  42. 代替戦略 逆を行く 可能な限り素の TypeScript で頑張る エラーハンドリング: discriminated union と early

    return neverthrow のようなライブラリも導入しない 高階関数 DI 手配線のコストを受け入れる AsyncLocalStorage を用いたロガー 面倒さを許容する(Go 的発想) TSKaigi 2026 asa1984 / HERP, Inc. 73 / 77
  43. 再考: TypeScript バックエンド 「侵襲性の低い、シンプルな構成」というと聞こえはいい。 しかし、 規模が大きくなったとき、十分な保守性を確保できるか? TypeScript はそれに耐えうる言語なのか? そんな事はなくて、誰かが意志をもって統制する必要がある もちろん

    オーバーエンジニアリングは避ける hype に流されない(重要) TypeScript をバックエンドに採用すること自体が割り切った選択である → 真面目に TypeScript バックエンドに向き合おう。 TSKaigi 2026 asa1984 / HERP, Inc. 76 / 77