Effect.provideService

In the previous lesson we saw how to define services using Context.Tag. We also saw that Effect.runSync would not allow us to run an effect that has requirements. Effect.provideService allows us to bind an implementation to those services, allowing us to run the effect.

Let’s see how this fits together.

import { Context, Data, Effect } from "effect";

type Coin = "HEADS" | "TAILS";

interface Game {
	flip: Effect.Effect<Coin>;
	roll: Effect.Effect<number>;
}

class GameService extends Context.Tag("@fred.c/GameService")<GameService, Game>() {}

class YouLose extends Data.TaggedError("@fred.c/YouLose")<{}> {}

const random = Effect.sync(() => Math.random());
const randomGame = {
	roll: random.pipe(Effect.map((v) => Math.ceil(v * 6))),
	flip: random.pipe(Effect.map((x): Coin => (x > 0.5 ? "HEADS" : "TAILS"))),
};

//       ┌─── Effect.Effect<number, YouLose, GameService>
//       ▼
const program = Effect.gen(function* () {
	const game = yield* GameService;
	let total = yield* game.roll;
	const flip = yield* game.flip;
	if (flip === "HEADS") {
		total += yield* game.roll;
	}
	if (total < 5) {
		yield* new YouLose();
	}
	return total;
});

program.pipe(
	Effect.map(console.log),
	Effect.catchAll(console.error),
	Effect.provideService(GameService, randomGame),
	Effect.runSync,
);

We have provided an implementation of the GameService service. The call to Effect.provideService essentially stores a key-value pair "@fred.c/GameService": randomGame somewhere behind the scenes. When the Effect is run the runtime see the instruction yield* GameService and now it knows how to interpret that instruction: look for the identifier "@fred.c/GameService" and return the corresponding value for that key.

Let’s look at the signature in this particular instance.

const provideService: <typeof GameService>(tag: typeof GameService, service: Game) => <A, E, R>(self: Effect.Effect<A, E, R>) => Effect.Effect<A, E, Exclude<R, GameService>> (+1 overload)

There are two things to note. First, the type of the service implementation that can be provided must match the type of the service tag. This is why GameService takes Game as one of its generic parameters: only a Game-shaped peg will fit in the Game-shaped hole. It’s also why we use the tag class as the key, rather than the plain string "@fred.c/GameService". The raw string identifier has no associated type information to tell us how this requirement can be satisfied.

The second thing worth noting is the return type. Much like other combinators we have seen (Effect.map, Effect.catchTag), Effect.provideService takes an Effect as an argument and produces an Effect. Effect.map transformed the first (A) type parameter. Effect.catchTag transformed the second (E) type parameter. Effect.provideService transforms the third (R) type parameter. The returned Effect excludes GameService from the requirements. This is saying: the hole is filled, and no longer needs to be provided. Once all holes have been filled, the third parameter will be never (“no unmet requirements”), indicating that this Effect is ready to run.

Exclude<GameService, GameService> = never
Exclude<GameService | OtherRequirement, GameService> = OtherRequirement
Exclude<R, R> = never
Exclude<R1 | R2, R1> = R2

Recall lesson 1 on Effect.succeed where we observed that the third generic type parameter defaults to never.

// These types (A, B, C) are all the same.
// We could refer to this as "an Effect of number".
type A = Effect.Effect<number, never, never>
type B = Effect.Effect<number, never>
type C = Effect.Effect<number>

The requirements type has always been in the background, but because it was never we did not need to think about it.

Let’s revisit the signature of Effect.runSync:

export declare const runSync: <A, E>(effect: Effect<A, E>) => A

In particular, it only accepts an Effect that has requirements of never. This means it will be a type error to run an Effect until we provide all service dependencies it requires. This is very helpful!

It is possible to have multiple services. Each one is provided separately. The Effect can only be run once all services have been provided.

class MinimumScore extends Context.Tag("@fred.c/MinimumScore")<
	MinimumScore,
	number
>() {}

//       ┌─── Effect.Effect<number, YouLose, GameService | MinimumScore>
//       ▼
const multipleServices = Effect.gen(function* () {
	const game = yield* GameService;
	const minimumScore = yield* MinimumScore;
	let total = yield* game.roll;
	const flip = yield* game.flip;
	if (flip === "HEADS") {
		total += yield* game.roll;
	}
	if (total < minimumScore) {
		yield* new YouLose();
	}
	return total;
});

multipleServices.pipe(
	Effect.provideService(GameService, randomGame),
	Effect.provideService(MinimumScore, 5),
	Effect.map(console.log),
	Effect.runSync,
);

Now we see the real benefit of the tags: the type of the implementation must match up with the type of the service. Effect.provideService(GameService, 5) would not make sense, nor would Effect.provideService(MinimumScore, randomGame). In some scenarios it may make sense to provide the same implementation for multiple tags, e.g. if we had a FlipService and a RollService then randomGame could provide both.

So far we have provided all services right at the edge, next to runSync. This does not need to be the case. It is possible to provide a service locally. This allows different implementations to be provided in different areas. For example you might have Effect.provideService(LogLevel, "INFO") at the root, but override this in one specific part of your codebase with Effect.provideService(LogLevel, "DEBUG").

The usual rules of scoping apply. Let’s see an example with the tests from the previous lesson.

import assert from "node:assert";

const provideGoodLuck = Effect.provideService(
	GameService,
	GameService.of({
		flip: Effect.succeed("HEADS"),
		roll: Effect.succeed(6),
	}),
);

const provideBadLuck = Effect.provideService(
	GameService,
	GameService.of({
		flip: Effect.succeed("TAILS"),
		roll: Effect.succeed(1),
	}),
);

const testCanWin = multipleServices.pipe(
	Effect.map((result) => {
		assert.equal(result, 12);
	}),
);

const testCanWinEasyMode = multipleServices.pipe(
	Effect.map((result) => {
		assert.equal(result, 1);
	}),
    provideBadLuck,
    Effect.provideService(MinimumScore, 1),
);

const testCanLose = multipleServices.pipe(
	Effect.map((result) => {
		assert.fail(`unexpectedly succeeded with ${result}`);
	}),
	Effect.catchAll((e) => {
		assert.equal(e.name, "YouLose");
		return Effect.succeed(e);
	}),
	provideBadLuck,
);

Effect.gen(function* () {
	yield* testCanWin;
	yield* testCanWinEasyMode;
	yield* testCanLose;
}).pipe(
	Effect.provideService(MinimumScore, 5),
	provideGoodLuck,
	Effect.map(() => console.log("tests passed")),
	Effect.runSync,
);

We are now able to run all tests as a single Effect. testCanWin uses both service implementations from the top level scope, testCanWinEasyMode overrides both service implementations locally, and testCanLose mixes and matches.

The scoping behaves exactly the same as it does for normal variables. This non-Effect code would run almost exactly the same logic.

interface Requirements {
	game: {
		flip: () => Coin;
		roll: () => number;
	};
	minimumScore: number;
}

const goodLuck = {
	flip: () => "HEADS" as const,
	roll: () => 6,
};
const badLuck = {
	flip: () => "TAILS" as const,
	roll: () => 1,
};

const minimumScore = 5;
const game = goodLuck;

const play = ({ game, minimumScore }: Requirements) => {
	let total = game.roll();
	const flip = game.flip();
	if (flip === "HEADS") {
		total += game.roll();
	}
	if (total < minimumScore) {
		throw new YouLose();
	}
	return total;
};

const testCanWin = () => {
	const result = play({ game, minimumScore });
	assert.equal(result, 12);
};

const testCanLose = () => {
	const game = badLuck;
	assert.throws(() => play({ game, minimumScore }));
};

const testCanWinEasyMode = () => {
	const game = badLuck;
	const minimumScore = 1;
	const result = play({ game, minimumScore });
	assert.equal(result, 1);
};

testCanWin();
testCanLose();
testCanWinEasyMode();

It might seem confusing at first that services are provided at the “bottom” of the pipe. But recall what pipe actually does: each function is called with the previous result, acting as a kind of wrapper.

const testCanLose = multipleServices.pipe(
	Effect.map((result) => {
		assert.fail(`unexpectedly succeeded with ${result}`);
	}),
	Effect.catchAll((e) => {
		assert.equal(e.name, "YouLose");
		return Effect.succeed(e);
	}),
	provideBadLuck,
);

const testCanLoseRewritten = provideBadLuck(
	multipleServices.pipe(
		Effect.map((result) => {
			assert.fail(`unexpectedly succeeded with ${result}`);
		}),
		Effect.catchAll((e) => {
			assert.equal(e.name, "YouLose");
			return Effect.succeed(e);
		}),
	),
);

Let’s recap

  • Context.Tag allows defining a service that can be used from an effect
  • Effect.provideService(tag, impl) allows providing a service
  • You can only run an Effect once all required services have been provided
  • This makes dependency injection simple, which helps with testing and configuration
  • Services can be provided regionally, with inner calls overriding outer calls