1017 words
5 minutes
[Effect Services] 06. 使用Context.Tag定义服务

使用Context.Tag定义服务#

https://github.com/typeonce-dev/effect-getting-started-course

WARNING

GenericTag 的局限性

使用 GenericTag 定义服务时存在一些隐藏问题:

  • 我们正在定义、引用和导出单独的值(维护复杂且更容易出错)
/// 1️⃣ 定义服务接口
export interface PokeApi {
  readonly getPokemon: Effect.Effect<
    Pokemon,
    FetchError | JsonError | ParseResult.ParseError | ConfigError
  >;
}

/// 2️⃣ 为服务定义 `Context`
export const PokeApi = Context.GenericTag<PokeApi>("PokeApi");

/// 3️⃣ 定义实现
export const PokeApiLive = PokeApi.of({
  getPokemon: Effect.gen(function* () {
    const baseUrl = yield* Config.string("BASE_URL");

    const response = yield* Effect.tryPromise({
      try: () => fetch(`${baseUrl}/api/v2/pokemon/garchomp/`),
      catch: () => new FetchError(),
    });

    if (!response.ok) {
      return yield* new FetchError();
    }

    const json = yield* Effect.tryPromise({
      try: () => response.json(),
      catch: () => new JsonError(),
    });

    return yield* Schema.decodeUnknown(Pokemon)(json);
  }),
});

index.ts

import { Effect } from "effect";
// 👇 为服务定义和实现分别/多次导入
import { PokeApi, PokeApiLive } from "./PokeApi";

const program = Effect.gen(function* () {
  const pokeApi = yield* PokeApi;
  return yield* pokeApi.getPokemon;
});

const runnable = program.pipe(Effect.provideService(PokeApi, PokeApiLive));
  • 我们面临服务类型之间冲突的风险(具有相同结构的服务)
export interface PokeApi {
  readonly getPokemon: Effect.Effect<
    Pokemon,
    FetchError | JsonError | ParseResult.ParseError | ConfigError
  >;
}

// ⛔️ 这是 2 个不同的导出,但它们引用相同的 `PokeApi` 接口
export const PokeApi1 = Context.GenericTag<PokeApi>("PokeApi1");
export const PokeApi2 = Context.GenericTag<PokeApi>("PokeApi2");

如果我们想要 100% 确保避免冲突,我们需要添加另一个 interface,使用 Context.GenericTag第一个类型参数使每个服务唯一:

// 👇 `Symbol` 使这个实例唯一
interface _PokeApi1 {
  readonly _: unique symbol;
}

export const PokeApi1 = Context.GenericTag<_PokeApi1, PokeApi>("PokeApi1");


// 👇 `Symbol` 使这个实例唯一
interface _PokeApi2 {
  readonly _: unique symbol;
}

export const PokeApi2 = Context.GenericTag<_PokeApi2, PokeApi>("PokeApi2");

当然,Effect 为此提供了解决方案:classContext.Tag

使用Context.Tag的服务类#

我们可以使用 classContext.Tag 更轻松地定义服务:

  • 服务标签 “PokeApi”

  • 第一个类型参数与 class 名称相同(PokeApi

  • 第二个类型参数是服务的签名(PokeApiImpl

interface PokeApiImpl {
  readonly getPokemon: Effect.Effect<
    Pokemon,
    FetchError | JsonError | ParseResult.ParseError | ConfigError
  >;
}

export class PokeApi extends Context.Tag("PokeApi")<PokeApi, PokeApiImpl>() {}
TIP

Context.Tag 的优势

我们将完整的服务定义简化为一个值

  • 由于 PokeApi 是一个 class,它既可以作为值也可以作为类型

  • Context.Tag 确保服务是唯一的(内部)

  • 我们可以在 class 内部定义方法和属性,这些将对服务的任何实例都可访问。我们使用这个来将服务实现定义为 static 属性

interface PokeApiImpl {
  readonly getPokemon: Effect.Effect<
    Pokemon,
    FetchError | JsonError | ParseResult.ParseError | ConfigError
  >;
}

export class PokeApi extends Context.Tag("PokeApi")<PokeApi, PokeApiImpl>() {
  static readonly Live = PokeApi.of({
    getPokemon: Effect.gen(function* () {
      const baseUrl = yield* Config.string("BASE_URL");

      const response = yield* Effect.tryPromise({
        try: () => fetch(`${baseUrl}/api/v2/pokemon/garchomp/`),
        catch: () => new FetchError(),
      });

      if (!response.ok) {
        return yield* new FetchError();
      }

      const json = yield* Effect.tryPromise({
        try: () => response.json(),
        catch: () => new JsonError(),
      });

      return yield* Schema.decodeUnknown(Pokemon)(json);
    }),
  });
}

Context.Tag 内部的 static readonly Live 是 Effect 中常见且推荐的模式。

它允许直接从 PokeApi 引用所有实现(LiveTestMock只需单次导入

有了这个,我们只需导入 PokeApi

index.ts

import { Effect } from "effect";
import { PokeApi } from "./PokeApi";

const program = Effect.gen(function* () {
  const pokeApi = yield* PokeApi;
  return yield* pokeApi.getPokemon;
});

const runnable = program.pipe(Effect.provideService(PokeApi, PokeApi.Live));

const main = runnable.pipe(
  Effect.catchTags({
    FetchError: () => Effect.succeed("Fetch error"),
    JsonError: () => Effect.succeed("Json error"),
    ParseError: () => Effect.succeed("Parse error"),
  })
);

Effect.runPromise(main).then(console.log);

使用 Context.Tag 而不是 Context.GenericTag最佳实践


再次,我们为一个开始时”简单”的 API 请求添加了大量代码。

PokeApi.ts

import { Config, Context, Effect, Schema, type ParseResult } from "effect";
import type { ConfigError } from "effect/ConfigError";
import { FetchError, JsonError } from "./errors";
import { Pokemon } from "./schemas";

interface PokeApiImpl {
  readonly getPokemon: Effect.Effect<
    Pokemon,
    FetchError | JsonError | ParseResult.ParseError | ConfigError
  >;
}

export class PokeApi extends Context.Tag("PokeApi")<PokeApi, PokeApiImpl>() {
  static readonly Live = PokeApi.of({
    getPokemon: Effect.gen(function* () {
      const baseUrl = yield* Config.string("BASE_URL");

      const response = yield* Effect.tryPromise({
        try: () => fetch(`${baseUrl}/api/v2/pokemon/garchomp/`),
        catch: () => new FetchError(),
      });

      if (!response.ok) {
        return yield* new FetchError();
      }

      const json = yield* Effect.tryPromise({
        try: () => response.json(),
        catch: () => new JsonError(),
      });

      return yield* Schema.decodeUnknown(Pokemon)(json);
    }),
  });
}

index.ts

import { Effect } from "effect";
import { PokeApi } from "./PokeApi";

const program = Effect.gen(function* () {
  const pokeApi = yield* PokeApi;
  return yield* pokeApi.getPokemon;
});

const runnable = program.pipe(Effect.provideService(PokeApi, PokeApi.Live));

const main = runnable.pipe(
  Effect.catchTags({
    FetchError: () => Effect.succeed("Fetch error"),
    JsonError: () => Effect.succeed("Json error"),
    ParseError: () => Effect.succeed("Parse error"),
  })
);

Effect.runPromise(main).then(console.log);

Effect Playground

事实证明,服务仍然可能过于有限。如果我们有更多相互依赖的服务会怎样?

我们不想多次创建它们或在任何地方手动使用 Effect.provideService

欢迎使用 Layer!让我们跳到下一个模块来学习如何使用它!

[Effect Services] 06. 使用Context.Tag定义服务
https://0bipinnata0.my/posts/course/effect-beginners-complete-getting-started/effect-services/06-define-services-with-context-tag/
Author
0bipinnata0
Published at
2025-08-30 17:48:36