These characteristics are what’s usually called implementation details. Notice how the algebra does not assume anything about that — it only defines a set of actions (methods) that could be performed on an “instance”. Preheating speed of the oven? Not an algebra’s business. Power source? Neither. Again, all these details are private to interpreters.

Let’s look at an example. I chose to implement an ElectricOven , because that’s the type that I own. Again, to keep the code as concise as possible, there’s nothing special about that particular implementation. You can imagine, however, what would be the differences between oven types in real life (again — preheating speed, baking time and/or temperature— you name it).

An electric oven implementation ⚡⚡⚡.

But there’s more to that — it’s not only about creating a class that implements a certain trait, but also choosing the type of your effect, defined by some certain characteristics (you could also call them boundaries, but I rather like to think about effect’s capabilities). Take a closer look at that line:

Read it aloud with me: the effect type here needs to be an instance of a “Sync” typeclass. What’s “Sync”? Well, again that’s beyond the scope of this article, but in short, it’s about having the capability of suspending the execution of side-effecting code (for more information, please refer to cats-effect docs).

Because Sync indirectly inherits from MonadError , the “concrete” effect type will have the feature of signalizing an execution error. It’s actually taken advantage of inside the bake method — just notice what happens if the temperature is too low:

Oh noes!

We’re raising an error we’ve defined before! Well, not actually “throwing” in these old OO-terms you’ve been used too from your Java class, but actually executing a “failure” execution path of our business logic. No ugly traces or stack unwinding!

Now you may be asking yourself: could we swap these effect type boundaries to something more relaxed or more restrictive? Of course! The hierarchy of typeclasses in cats-effect is pretty extensive, and you can fine-tune your choice based on the requirements.

Putting it all together

As we’ve started at the most abstract end of the spectrum, we’re coming close to putting it all together. So far, we’ve got all the elements we need: the algebra defines what to do. The domain model defines our ingredients. Interpreters & effects define the way we take actions.

It’s time to move to the so-called “end of the world”, and I don’t mean any disastrous events! What I’m thinking of, is the main function, an entry point to the application, where we will compose actions into a program.

Remember, though, that we still need to choose a “concrete” instance of the Sync typeclass. One such instance is the IO type from cats-effect. This is the type that wraps all possibly side-effectful computations and fulfills the rules enforced by the Sync typeclass. This will become our runtime, the kitchen in which we’ll produce a delicious bread. Let’s use it:

We’ve composed the steps into a nice baking program in the for-comprehension expression. Notice how additional console-printing statements have also been wrapped in the IO , deferring their side-effectful execution until the program is actually run!

Go ahead, run the program. You should get the following output:

Preheating the electric oven... done!

Baking the Bread(1)...

The bread grows twice in size!

Here's your bread: Bread(2) Process finished with exit code 0

Congratulations, the baking program works as expected! We’ve received a baked bread that grew twice its’ initial size.

Did you notice, however, the recoverWith method call on line 23? There’s a log statement that didn’t appear in our output… of course it didn’t, as there were no errors in the process! The recoverWith ’s function callback would only be called if any error had been raised before — remember that raiseError in the ElectricOven 's bake method that I’ve mentioned before?

We can actually trigger that path of execution. It’s as simple as skipping the oven’s preheating. Observe:

We’re now bravely going to use the cold oven. Here’s the output:

Using the cold oven, let's see what happens!

Baking the Bread(1)...

The oven was cold, your bread might not be good to eat :(

Here's your bread: Bread(1) Process finished with exit code 0

This time, the log statement from the failure-recovering branch has been logged in the output! Notice also, how we got back our unbaked, ungrown-in-size bread.

Bonus — how they do it in other kitchens?

As it was pointed out earlier, the tagless final style allows us to choose the effect type, or — in other words — define the runtime. This choice, however, does not have to be bound by a particular library! What if we wanted to use ZIO instead of cats-effect?

The ZIO variation strongly resembles the previous example. How could it not? After all, the general idea is very much the same — it’s the under-the-hood implementation of the runtime that differs. Plus, as long as runtime type can interoperate with the typeclass used as a boundary on the effect type in our interpreters, we can swap libraries!

Same could be done with monix, but I’ll leave that as an exercise for the reader.

Summary

This is what tagless final is about: define your algebra, supported by the domain model & implement your interpreters, choosing a runtime & an effect type. Besides the nice separation between abstract services & their interpreters, this style gives us a great freedom of choice when it comes to swapping the “cogs” in the machine. As you’ve seen, switching from one runtime (and its’ associated effects) to another should be relatively straightforward in an ideal world.

I could not finish this post without recommending some of the materials I’ve been studying to understand the topic. While this article grew long already, I tried to keep it as simple as possible. The links below go further down the topic, embracing even more advanced techniques and patterns:

Last but not least, the tagless-final-bakery project is on github.