Effect TS の Effect System で GitHub API クライアント(技術課題レベル)を型安全に設計した
個人プロジェクトで Effect TS の Effect System(型付きエラー・依存注入・スキーマ)をフル活用して GitHub API クライアントを設計したので、学んだことをまとめます。
リポジトリ: https://github.com/shidari/github-repo-explorer
Effect System とは
Effect TS の核は Effect<Success, Error, Requirements> という3つの型パラメータ。
- Success: 成功時の戻り値
- Error: 起きうるエラーの型(Union で列挙)
- Requirements: 実行に必要な依存(DI)
この3つが型レベルで表現されるので、「この処理は何を返し、何が失敗し、何に依存するか」がシグネチャだけで全部わかる。
型付きエラー: ハンドリング漏れをコンパイル時に検出
GitHub API のエラーを API ごとに分類した:
// Search API のエラー
class SearchNoResultError extends Data.TaggedError("SearchNoResultError") { ... }
class SearchApiUnexpectedError extends Data.TaggedError("SearchApiUnexpectedError")<{
reason: "rateLimit" | "validation" | "serviceUnavailable" | "unknown"
}> {}
// Repos API のエラー
class RepoNotFoundError extends Data.TaggedError("RepoNotFoundError") { ... }
class ReposApiUnexpectedError extends Data.TaggedError("ReposApiUnexpectedError")<{
reason: "rateLimit" | "unknown"
}> {}
// Search API のエラー
class SearchNoResultError extends Data.TaggedError("SearchNoResultError") { ... }
class SearchApiUnexpectedError extends Data.TaggedError("SearchApiUnexpectedError")<{
reason: "rateLimit" | "validation" | "serviceUnavailable" | "unknown"
}> {}
// Repos API のエラー
class RepoNotFoundError extends Data.TaggedError("RepoNotFoundError") { ... }
class ReposApiUnexpectedError extends Data.TaggedError("ReposApiUnexpectedError")<{
reason: "rateLimit" | "unknown"
}> {}
Data.TaggedError で定義すると _tag プロパティが自動付与され、パターンマッチで網羅チェックが効く:
Effect.match({
onFailure: (err) => {
switch (err._tag) {
case "SearchNoResultError":
return c.json({ message: `No results for "${err.query}"` }, 404);
case "SearchApiUnexpectedError":
// err.reason で rateLimit / validation / serviceUnavailable を分岐
...
default:
return err satisfies never; // ← ハンドリング漏れはコンパイルエラー
}
},
});
Effect.match({
onFailure: (err) => {
switch (err._tag) {
case "SearchNoResultError":
return c.json({ message: `No results for "${err.query}"` }, 404);
case "SearchApiUnexpectedError":
// err.reason で rateLimit / validation / serviceUnavailable を分岐
...
default:
return err satisfies never; // ← ハンドリング漏れはコンパイルエラー
}
},
});
satisfies never で未処理のエラーがあればコンパイル時に検出される。try-catch では実現できない安全性。
Layer による依存注入: 本番とテストの切り替え
Context.Tag で依存を抽象化し、Layer で実装を注入する:
class SearchReposQuery extends Context.Tag("SearchReposQuery")<...>() {
static readonly main = Layer.succeed(SearchReposQuery, { ... }); // GitHub API
static readonly test = Layer.succeed(SearchReposQuery, { ... }); // モックデータ
}
class SearchReposQuery extends Context.Tag("SearchReposQuery")<...>() {
static readonly main = Layer.succeed(SearchReposQuery, { ... }); // GitHub API
static readonly test = Layer.succeed(SearchReposQuery, { ... }); // モックデータ
}
アプリ全体の依存を NODE_ENV で一括切り替え:
Effect.provide(
process.env.NODE_ENV === "production"
? SearchReposQuery.main
: SearchReposQuery.test,
);
Effect.provide(
process.env.NODE_ENV === "production"
? SearchReposQuery.main
: SearchReposQuery.test,
);
テスト時は GitHub API も外部 DB も叩かず、シード固定のモックデータとインメモリ DB(PGlite)で動作する。Rate Limit の閾値もテスト用の値に差し替え可能。
Effect Schema: バリデーションと型推論の一元化
ドメインモデルを Effect Schema で定義すると、バリデーション・型推論・モックデータ生成が1つの定義から導出される:
const Repository = Schema.Struct({
full_name: Schema.NonEmptyString,
html_url: HttpsUrl,
owner: Owner,
stargazers_count: Schema.NonNegativeInt,
// ...
});
type Repository = typeof Repository.Type; // ← 型が自動導出
const Repository = Schema.Struct({
full_name: Schema.NonEmptyString,
html_url: HttpsUrl,
owner: Owner,
stargazers_count: Schema.NonNegativeInt,
// ...
});
type Repository = typeof Repository.Type; // ← 型が自動導出
GitHub API のレスポンスを Schema.decodeUnknown(Repository)(data) に通すだけで、型安全なドメインオブジェクトに変換できる。不正なデータは Effect のエラーチャネルに自動で流れる。
所感
Effect System を使うと「成功・エラー・依存」が全て型に載るので、コードを読むだけで処理の全体像が把握できる。特に外部 API 連携のような「何が失敗するかわからない」領域で真価を発揮した。
学習コストは高いが、ドメインが複雑になるほど投資が回収される感覚がある。