Context.Tag

We have seen how Effect allows us to piece together small programs to build larger, more complex ones, in a resilient way. By wrapping each instruction as an Effect, we have made execution lazy by default, which gives us control over when our code runs. We can also separate successes from failures without relying on throw, allowing us to document and handle errors in a type-safe way. As our programs get more complex, it’s important to be able to test the individual pieces in isolation. Context.Tag helps us define services that can behave as swappable parts, helping us isolate the behaviour we want to test.

Let’s take a look at an example program using the pieces we’ve seen so far.

import { Data, Effect } from "effect";

type Coin = "HEADS" | "TAILS";

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

class YouLose extends Data.TaggedError("YouLose")<{}> {}

const program = Effect.gen(function* () {
    let total = yield* roll;
    const flip = yield* flip;
    if (flip === "HEADS") {
        total += yield* roll;
    }
	if (total < 5) {
		yield* new YouLose();
	}
    return total;
});

program.pipe(Effect.map(console.log), Effect.runSync);

Although each piece is small, it would be hard to check whether program behaves as expected, because program builds on top of roll and flip, which in turn build on top of random. As our application gets bigger, this web of dependencies would only grow. We’re considering a simple random function here, but the same would be true of code that runs any side effects, like making network requests, database calls, or reading files.

Let’s see what some simple tests for this program could look like.

import assert from "node:assert";

// it should be possible to win
const luckyGame = {
	flip: Effect.succeed("HEADS" as const),
	roll: Effect.succeed(6),
};
makeProgram(luckyGame).pipe(
	Effect.map((result) => {
		assert.equal(result, 12);
	}),
	Effect.runSync,
);

// it should be possible to lose
const unluckyGame = {
	flip: Effect.succeed("TAILS" as const),
	roll: Effect.succeed(1),
};
makeProgram(unluckyGame).pipe(
	Effect.map((result) => {
		assert.fail(`unexpectedly succeeded with ${result}`);
	}),
	Effect.catchAll((e) => {
		assert.equal(e.name, "YouLose");
		return Effect.succeed(e);
	}),
	Effect.runSync,
);

Let’s refactor the code to make this kind of test possible.

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

const makeProgram = (game: Game) => Effect.gen(function* () {
    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;
});

const randomGame: Game = { flip, roll };
makeProgram(randomGame).pipe(
    Effect.map(console.log), // output: ???
    Effect.catchAll((e) => Effect.succeed(console.error(e))), // output: ???
    Effect.runSync,
);

By explicitly passing in the Game dependency, we have made it possible to write tests. This kind of dependency injection is common, but it can spiral out of control. Any time the dependencies of a function change, not only do you have to change the call signature of the function, but also the signature of every function that calls it. This is similar to the problem of “prop-drilling” in component-based UI libraries like React.

Let’s take a look at an alternative implementation using Context.Tag.

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

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

//       ┌─── Effect.Effect<number, YouLose, GameService>
//       ▼
const withService = 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;
});

Rather than passing in the game as an argument, we “summon” one using yield* GameService. Otherwise, the code is very similar to our original workflow, without the dependency injection.

Note the type signature of our withService Effect specifies GameService in the third generic parameter. This parameter is the “requirements”. We can read the type Effect.Effect<A, E, R> as “an Effect that will produce a value of type A when run, unless it fails with an error of type E, and that needs to be provided with a context of type R in order to run”.

Note that we still get the same level of type safety as with the makeProgram version. makeProgram required that we pass in a Game, and withService requires a GameService to be provided to it.

We are no longer able to run our Effect:

// Argument of type 'Effect<number, YouLose, GameService>' is not assignable to parameter of type 'Effect<number, YouLose, never>'.
// Type 'GameService' is not assignable to type 'never'.
Effect.runSync(withService);

This is a type error, but it would also fail at runtime. This makes sense! We have specified that our effect requires a GameService, but we have not told it which implementation of GameService to use. Should it use randomGame, luckyGame, or some other implementation? We will see how to provide services to our effects in the next lesson (Effect.provideService).

Our service comes with a few bonus helpers. GameService.of allows us to wrap implementations of our service, which can help with type checking and autocomplete. GameService["Id"] returns the type of the string identifier for this service (@myapp/GameService) and GameService["Type"] returns the unwrapped type that this service yields (Game). We can equivalently use Context.Tag.Identifier<GameService> and Context.Tag.Service<GameService>.

//       ┌─── Game
//       ▼
const luckyGame = GameService.of({
	flip: Effect.succeed("HEADS"),
	roll: Effect.succeed(6),
});

const samePlayer: GameService["Type"] = luckyGame;
const extractedId: GameService["Id"] = "@myapp/GameService";

Because GameService is defined using class syntax, we are able to use the same identifier as a value (yield* GameService, GameService.of) and as a type (GameService["Type"], Context.Tag.Identifier<GameService>) without repetitive declarations.

There is nothing special about the yield syntax. We can use pipe syntax too, if preferred.

const isBigRoll = GameService.pipe(Effect.flatMap(game => game.roll === 6));

Our “service” can be any type that we want, not just an object. Here is an example where we extract the “minimum score” value, so that it could be passed in as a configuration value.

class MinimumScore extends Context.Tag("@myapp/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;
});

The requirements of this effect have grown to GameService | MinimumScore, similar to how the error types combine when working with multiple effects. Although | often means “or”, in this case “union” would be a more accurate term. The set of requirements has grown from GameService to the union of GameService and MinimumScore. If we were trying to write this effect as a function using dependency injection, we might write a function like this:

interface Requirements {
    game: Game;
    minimumScore: MinimumScore;
}
const makeProgram = ({game, minimumScore}: Requirements): Effect.Effect<number, YouLose> = ...

You can think of GameService | MinimumScore as an object where the keys are service identifiers and the values are service implementations. Service identifiers need to be unique for each service so that there are no collisions.

We would not be able to uniquely identify services just from looking at the implementation. For example, if we wanted a MinimumScore and a MaximumScore then these would both be of type number. As with Data.TaggedError, the tag helps us differentiate. The requirements type would be GameService | MinimumScore | MaximumScore in this case, as opposed to Game | number | number which would simplify to Game | number.

Note that we can split our program apart if it gets too big. Unlike calling a function, we do not have to worry too much about which dependencies need passing around to which parts.

//       ┌─── (score: number) => Effect.Effect<void, YouLose, MinimumScore>
//       ▼
const check = (score: number) =>
	Effect.gen(function* () {
		const minimumScore = yield* MinimumScore;
		if (score < minimumScore) {
			yield* new YouLose();
		}
	});


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

//     ┌─── Effect.Effect<number, YouLose, GameService | MinimumScore>
//     ▼
const main = Effect.gen(function* () {
	const total = yield* calculateTotal;
	yield* check(total);
	return total;
});

Finally, it’s worth noting a common convention. As with Data.TaggedError it is considered best practice to namespace your services, to make sure they are globally unique. A service is often defined in the same module as the “real” implementation. The advantage here is brevity. By using typeof you can avoid having to type out the service interface. You can also attach instances of the service as static members of the class. It is common to use the names Live and Test to refer to these instances.

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

export class GameService extends Context.Tag("@fred.c/GameService")<
	GameService,
	typeof instance
>() {
	static Live = this.of(instance);
	static Test = this.of(luckyGame);
}

This minimises the amount of typing that you, the author, need to do. It also means there is only one symbol to import in order to access the service, both to use it (yield* GameService) and to provide it (GameService.Live or GameService.Test).

Let’s recap:

  • Context.Tag allows defining a service
  • The tag has an identifier and a service type
  • An Effect can access a service by yielding or piping it
  • Requirements are stored in the third generic parameter: Effect.Effect<Actual, Error, Requirements>
  • You cannot run an Effect that requires services to be provided to it