There are some hidden problems when defining a service using GenericTag
:
- We are defining, referencing, and exporting separate values (complex to maintain and more error-prone)
/// 1️⃣ Define service interface
export interface PokeApi {
readonly getPokemon: Effect.Effect<
Pokemon,
FetchError | JsonError | ParseResult.ParseError | ConfigError
>;
}
/// 2️⃣ Define `Context` for service
export const PokeApi = Context.GenericTag<PokeApi>("PokeApi");
/// 3️⃣ Define implementation
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);
}),
});
import { Effect } from "effect";
// 👇 Separate/Multiple imports for service definition and implementations
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));
- We risk having conflicts between service types (services with the same structure)
export interface PokeApi {
readonly getPokemon: Effect.Effect<
Pokemon,
FetchError | JsonError | ParseResult.ParseError | ConfigError
>;
}
// ⛔️ These are 2 different exports but they reference the same `PokeApi` interface
export const PokeApi1 = Context.GenericTag<PokeApi>("PokeApi1");
export const PokeApi2 = Context.GenericTag<PokeApi>("PokeApi2");
If we want to be 100% sure to avoid conflicts we would need to add another interface
to make each service unique using the first type parameter of Context.GenericTag
:
// 👇 `Symbol` makes this instance unique
interface _PokeApi1 {
readonly _: unique symbol;
}
export const PokeApi1 = Context.GenericTag<_PokeApi1, PokeApi>("PokeApi1");
// 👇 `Symbol` makes this instance unique
interface _PokeApi2 {
readonly _: unique symbol;
}
export const PokeApi2 = Context.GenericTag<_PokeApi2, PokeApi>("PokeApi2");
Of course effect has a solution for this: class
and Context.Tag
.
Service class with Context.Tag
We can more easily define services using a class
and Context.Tag
:
- Service tag "PokeApi"
- First type parameter same as
class
name (PokeApi
) - Second type parameter is the signature of the service (
PokeApiImpl
)
interface PokeApiImpl {
readonly getPokemon: Effect.Effect<
Pokemon,
FetchError | JsonError | ParseResult.ParseError | ConfigError
>;
}
export class PokeApi extends Context.Tag("PokeApi")<PokeApi, PokeApiImpl>() {}
We reduced the full service definition to one value:
- Since
PokeApi
is aclass
it works both as a value and type Context.Tag
makes sure that the service is unique (internally)- We can define methods and attributes inside
class
that will be accessible for any instance of the service. We use this to define the service implementation as astatic
attribute
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);
}),
});
}
static readonly Live
insideContext.Tag
is a common and recommended pattern in effect.It allows to reference all implementations (
Live
,Test
,Mock
) directly fromPokeApi
with a single import.
With this we can import only PokeApi
:
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);
It's a best practice to use
Context.Tag
instead ofContext.GenericTag
.
Again, we added a bunch of more code for something that started as a "simple" API request.
import { Schema, type ParseResult } from "@effect/schema";
import { Config, Context, Effect } 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);
}),
});
}
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);
Turns out that services can be still too limited. What happens if we have more services that depend on each other?
We do not want to create them more than once or use Effect.provideService
manually everywhere.
Welcome Layer
! Let's jump to the next module to learn how to use it!