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 Layers.

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 Runtimes for a while, but over the next few lessons we’ll see how to use Layers 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
  • Layers will allow us to define composable service constructors