Effect.provide
Over the last few lessons we have seen how to work with Context on its own. But the main value comes from using a Context in conjunction with Effect.
Rather than passing individual services to Effect, we can pass an entire context. Here’s a basic example using our familiar CoinService
.
import { Context, Effect } from "effect";
// 1. define services and workflows
type Coin = "HEADS" | "TAILS";
class CoinService extends Context.Tag("@fred.c/CoinService")<
CoinService,
{ flip: Effect.Effect<Coin> }
>() {}
const main = CoinService.pipe(
Effect.map((coin) => console.log(`You flipped ${coin}`)),
);
// 2. define implementation
const coinServiceImpl = { flip: Effect.succeed("HEADS" as const) };
const context = Context.make(CoinService, coinServiceImpl);
// 3. run
main.pipe(
// previously: Effect.provideService(CoinService, coinServiceImpl),
Effect.provide(context),
Effect.runSync,
);
With a single service, this looks very similar to what we did with Effect.provideService
. In fact, we can write Effect.provideService
in terms of Effect.provide
. Here is a slimmed-down implementation:
const provideService = <I, S>(tag: Context.Tag<I, S>, service: S) =>
Effect.provide(Context.make(tag, service));
Context really shines when there are multiple services. We can group all of the service definitions together and workflows in one module. We can group all of the logic to construct service instances together in another module. Then our entry point can be a basic script that takes a workflow and a context, and runs the workflow using the context.
Here’s an example. First we design services and workflows in lib.ts
:
// --- lib.ts ---
import { Context, Data, Effect } from "effect";
import assert from "node:assert";
export type Coin = "HEADS" | "TAILS";
export class DiceService extends Context.Tag("@fred.c/DiceService")<
DiceService,
{ roll: Effect.Effect<number> }
>() {}
export class CoinService extends Context.Tag("@fred.c/CoinService")<
CoinService,
{ flip: Effect.Effect<Coin> }
>() {}
class YouLose extends Data.TaggedError("@fred.c/YouLose")<{}> {}
export const business = Effect.gen(function* () {
const dice = yield* DiceService;
const coin = yield* CoinService;
let total = yield* dice.roll;
const flip = yield* coin.flip;
if (flip === "HEADS") {
total += yield* dice.roll;
}
if (total < 5) {
yield* new YouLose();
}
return total;
});
Next we create “real” implementations of each service in deps.ts
. Note that we only need to export the root context.
// --- deps.ts ---
// contains the "real" implementation of each service
import { Context, Effect } from "effect";
import { Coin, CoinService, DiceService } from "./lib";
const nextRandom = Effect.sync(() => Math.random());
const realDice = DiceService.of({
roll: nextRandom.pipe(Effect.map((v) => Math.ceil(v * 6))),
});
const realCoin = CoinService.of({
flip: nextRandom.pipe(Effect.map((x): Coin => (x > 0.5 ? "HEADS" : "TAILS"))),
});
export const rootContext = Context.empty().pipe(
Context.add(DiceService, realDice),
Context.add(CoinService, realCoin),
);
Finally, we stitch those together in our entrypoint, main.ts
. This is the only module that runs Effects - the other modules only define data structures.
// --- main.ts ---
import { Effect } from "effect";
import { business } from "./lib";
import { rootContext } from "./deps";
business.pipe(
Effect.provide(rootContext),
Effect.map((e) => console.log(e)),
Effect.catchAll((e) => Effect.succeed(console.error(e))),
Effect.runSync,
);
We can also write tests for our library, swapping out the implementation for a different context.
// --- lib.test.ts ---
import assert from "node:assert";
import { Context, Effect } from "effect";
import { business, Coin, CoinService, DiceService } from "./lib";
const makeTestContext = (rolls: number = 6, flips: Coin = "HEADS") =>
Context.empty().pipe(
Context.add(DiceService, { roll: Effect.succeed(rolls) }),
Context.add(CoinService, { flip: Effect.succeed(flips) }),
);
const testCanWin = Effect.gen(function* () {
const result = yield* business;
assert.equal(result, 12);
});
const testCanLose = Effect.gen(function* () {
const result = yield* business;
assert.fail(`unexpectedly succeeded with ${result}`);
}).pipe(
Effect.catchAll((e) => {
assert.equal(e.name, "YouLose");
return Effect.succeed(e);
}),
Effect.provide(makeTestContext(1, "TAILS")),
);
Effect.gen(function* () {
yield* testCanWin;
yield* testCanLose;
}).pipe(Effect.provide(makeTestContext()), Effect.runSync);
Using these structures we can build up increasingly complex apps. But what happens if the construction of our services is itself effectful? Previously we used Effect.provideServiceEffect
to provide a single service effectfully.
Let’s consider what this might look like. First build the context, then provide it to the main effect, then run the main effect.
const provideContextEffect =
<R>(context: Effect.Effect<Context.Context<R>>) =>
<A, E>(effect: Effect.Effect<A, E, R>): Effect.Effect<A, E> =>
Effect.gen(function* () {
const built = yield* context;
const ran = yield* effect.pipe(Effect.provide(built));
return ran;
});
But what happens if building the context goes wrong? This could absolutely happen: perhaps the system cannot read a config file, or establish a database connection. And how could we provide some of the services, but not all? For example, how can we construct a GameService
instance that relies on a RandomService
instance being available? This would quickly get much more complex, in terms of types being passed around.
const provideContextEffect =
<ROut, E1, RIn>(context: Effect.Effect<Context.Context<ROut>, E1, RIn>) =>
<A, E2, R>(
effect: Effect.Effect<A, E2, R>,
): Effect.Effect<A, E1 | E2, RIn | Exclude<R, ROut>> =>
Effect.gen(function* () {
const built = yield* context;
const ran = yield* effect.pipe(Effect.provide(built));
return ran;
});
In the Effect ecosystem, the answer is to use Layer
s.
Effect.provide
can take a Context
as parameter, but it can also take a Layer
or a Runtime
.
We won’t need to take a look at Runtime
s for a while, but over the next few lessons we’ll see how to use Layer
s to build an application.
Let’s recap
Effect.provide
can provide multiple services from a Context- This allows us to separate declarative workflow definitions from instantiating real instances of services
Layer
s will allow us to define composable service constructors