Effect Service
https://effect.website/docs/requirements-management/services/
https://www.typeonce.dev/course/effect-beginners-complete-getting-started/effect-services/refactoring-effect-code
Effect における Service は 依存性の注入 のために設計されており、かつ 型安全 である
Service の定義
通常 Service は Context を使用して定義される
Context は Service の実装を値に持つグローバルな Map
具体的な実装を提供すると、Effect はこのグローバルな Map から Service を抽出する
実際の定義は Context.GenericTag などを用いる
code:ts
// Service が提供するすべてのメソッドを含むインタフェース
export interface PokeApi {
readonly getPokemon: Effect.Effect<
Pokemon,
FetchError | JsonError | ParseResult.ParseError | ConfigError
;
}
export const PokeApi = Context.GenericTag<PokeApi>("PokeApi");
/icons/idea.icon interface と Service に同じ名前を付けることで、単一のインポートで型と値の両方を export できる
warning.icon Service はメソッドの集合というわけではなく、以下のように単一の値や関数でも良い
code:ts
export class PokemonCollection extends Context.Tag("PokemonCollection")<
PokemonCollection,
Array.NonEmptyArray<string>
() {}
export class PokeApiUrl extends Context.Tag("PokeApiUrl")<PokeApiUrl, string>() {}
export class BuildPokeApiUrl extends Context.Tag("BuildPokeApiUrl")<
BuildPokeApiUrl,
(props: { name: string }) => string
() {}
Context.Tag について後述: Effect Service#6839bcc30000000000cab87a
Service の利用
Service は通常の Effect.Effect として扱うことができるので、Effect.gen の場合は yield* するだけで良い
code:ts
const program = Effect.gen(function* () {
const pokeApi = yield* PokeApi;
return yield* pokeApi.getPokemon;
});
// ^ const program: Effect.Effect<
// Pokemon, FetchError | JsonError | ParseError | ConfigError, PokeApi
// >
warning.icon Service で提供しているメソッドも Effect なので yield* が必要
Effect の依存関係
Effect の 3 つ目の型パラメータは Service への依存関係を表す
Effect を実行する run* 関数はすべて、この型パラメータが never (すべての依存関係が提供済みであること)になっている
code:ts
export const runPromise: <A, E>(
effect: Effect<A, E, never>,
options?: { readonly signal?: AbortSignal } | undefined
) => Promise<A> = _runtime.unsafeRunPromiseEffect
そのため、たとえば上記の program をそのまま runPromise に渡すと型エラーとなる
code:ts
Effect.runPromise(program).then(console.log);
// Argument of type 'Effect<Pokemon, FetchError | JsonError | ParseError | ConfigError, PokeApi>'
// is not assignable to parameter of type 'Effect<Pokemon, FetchError | JsonError | ParseError |
// ConfigError, never>'. Type 'PokeApi' is not assignable to type 'never'.
これを解決するには、まず生成した Sevice を of メソッドでインスタンス化する必要がある
この中に実装を記述する
code:ts
export const PokeApiLive = PokeApi.of({
getPokemon: Effect.gen(function* () {
// ここに実装を記述
}),
});
/icons/idea.icon 慣習的に本番環境用のインスタンスには -Live という名前を付け、環境ごとに -Dev や -Test、-Mock など名前を変える
code:ts
export const PokeApiMock = PokeApi.of({
getPokemon: Effect.succeed({ id: 1, height: 10, weight: 10, order: 1, name: "myname" }),
});
そして、Effect.provideService を使って注入する
code:ts
const runnable = program.pipe(Effect.provideService(PokeApi, PokeApiLive));
// ^ const runnable: Effect.Effect<
// Pokemon, FetchError | JsonError | ParseError | ConfigError, never
// >
第 1 引数に Service の定義、第 2 引数にインスタンスを渡す
型を確認すると第 3 型パラメータが never になっており、依存が注入されていることが型レベルで確認できる
この Effect なら run* に渡すことができる
Effect.runPromise(runnable).then(console.log);
warning.icon 複数のサービスが相互依存する場合、都度インスタンスを生成したり、手動で provideService で注入したりするのは手間なので、Effect Layer を用いる
Context.GenericTag の問題点と Context.Tag
Context.GenericTag は同じインタフェースを持つ異なる型を定義できてしまう
code:ts
export const PokeApi1 = Context.GenericTag<PokeApi>("PokeApi1");
export const PokeApi2 = Context.GenericTag<PokeApi>("PokeApi2");
これを回避するには、代わりに クラス + Context.Tag を用いれば良い
export class PokeApi extends Context.Tag("PokeApi")<PokeApi, PokeApiImpl>() {}
これにより、Service の全環境の実装を 1 つの値に集約することができる
クラスなので値・型の両方として使える
Context.Tag が Service の一意性を保証(競合しない)
クラス内で静的属性やメソッドを追加可能
code:ts
export class PokeApi extends Context.Tag("PokeApi")<PokeApi, PokeApiImpl>() {
static readonly Live = PokeApi.of({
getPokemon: // 本番環境の実装
});
static readonly Mock = PokeApi.of({
getPokemon: // テスト用の実装
});
}
また、Effect.provideService に渡すのも PokeApi と PokeApi.Live となるので、import する値が 1 つ減る
const runnable = program.pipe(Effect.provideService(PokeApi, PokeApi.Live));
以上を踏まえると、Context.GenericTag の代わりに Context.Tag を使うことがベストプラクティス である
#Effect(TypeScript)