Devot Logo
Devot Logo
Arrow leftBack to blogs

Integrating YAES with Tagless Final: Capability-Based State Threading in Scala

Matija K.7 min readMay 29, 2025Technology
Matija K.7 min read
Contents:
State threading
Integrating with an existing tagless final codebase
Closing thoughts

If you have been following recent developments in the Scala ecosystem, you might have heard about YAES (Yet Another Effect System). First of all, big thanks to its author, Riccardo Cardin, and others who have helped out here!

It leverages implicits (context parameters) to thread effects seamlessly throughout the call stack. You can have, e.g., an effect for logging some data, without having to pass this effect around directly.

However, any call to such an effect, e.g. to log some data on the logger effect, is not actually executed immediately. The execution of such code is deferred, until an effect handler is provided that takes care of running the effectful computation.

Thus, it results in code that is written in direct style without the need for for-comprehensions, while also offering staged execution of effectful program descriptions. Sounds great, but what's the catch?

State threading

If you look at the currently available effects and the plans for future effects, you will notice the lack of a state effect. A State effect allows you to read values from the context and, more importantly, also write values for future consumption by downstream code.

A very straightforward implementation for a plain (non-monadic) State effect would be a case class that contains a mutable current state value, and some additional helper code under the hood. Then, such capability could be passed around using implicits like the rest of these effects:

However, we lose referential transparency when updating an existing MutState instance:

Let's convert it to an immutable state then, and create fresh instances when we need to update it, instead:

Great! However, if you try to update the state again and thus try to redeclare given MutState[Int] once more, the compiler will complain about Ambiguous given instance. There's a limitation in place that you can have only one given instance per scope.

We can work around this by assigning each MutState instance to its own variable:

This approach is quite error-prone. It’s easy to reference the wrong variable when updating state, or to forget passing the most up-to-date state as the appropriate implicit parameter to other functions.

By sacrificing a bit of referential transparency, which affects the current scope only and does not spill over to the rest of the call stack (!), this can be cleaned up significantly, though:

We still might forget to re-assign the new state to the existing variable. Let's try to encapsulate this, to reduce the surface for human error:

The state update looks much cleaner and safer now, and the implicit resolution works as expected, without having to pass the correct implicit instance manually! We still need these two lines of "preamble" code at the start of the program method, though, which could maybe somehow be made even more concise.

Unfortunately, there's another issue with this approach we haven't examined yet: how do we return the updated MutState to the caller? We would like to know the final value for MutState at the top of the call stack, thus we would like MutState.run to return a tuple (finalStateValue: Int, programResult: String).

If we make program return its final MutState as part of its return value def program(name: String)(using M0: MutState[Int], O: Output): (MutState[Int, String), we lose all the ergonomics and are missing the point of using effects seamlessly from the function's implicit environment.

Just like we can have implicit (context) arguments for a function, the idea crossed my mind to have implicit (context) return values for functions. I'm not sure whether that would be feasible or still result in a sound-type system in Scala, but, anyway, that's not an option right now.

Update:
Previously, I thought the only way to deal with this was to thread a single MutStateVar all the way from the beginning to the end of the call stack, where the MutState effect is needed. This would have resulted in losing referential transparency across the whole call stack.

However, with a new approach, we can achieve partial referential transparency though, after all. Namely, we have to adapt the implicit resolution via MutState.apply to return the most up-to-date MutState at all times. The MutState instances themselves can remain immutable, though. By having the MutState.run handler keep track of the most up-to-date state S, the whole mechanism works even across different call stacks!

Integrating with an existing tagless final codebase

As the library's name clearly states, it's yet another effect system. Even if this effect system's approach was the ultimate solution to all our problems and wishes for safe yet lean FP programming in Scala, no application already in production will be migrating its whole codebase to this new approach. You can have a state-of-the-art solution for a problem, if it's hard to apply and integrate in practice it won't be used unfortunately.

Supermonad effect systems may tell you otherwise, but Tagless Final is a powerful abstraction that allows you to be more flexible in what actual effect system implementation you use under the hood, at least in theory. In practice, compared to Typelevel (cats & co), Monix, Izumi (BIO) and tofu-tf, I have yet to see ZIO and kyo ecosystem offer typeclass instances for common effect types, if that even is feasible and makes sense on a technical level.

Prior to putting the cart before the horse, though, what is Tagless Final (TF), and how do you structure your application with it? I'd like to thank and refer to existing resources on the Web here, that explain that better than I ever could, for example:

What does an example TF application look like, then? Let's use the following contrived example, which uses a couple of TF typeclass abstractions:

To make the integration between TF and YAES work and to have YAES thus drive the execution of this code, we need to provide typeclass instances for these various effects that are used:

  • cats.effect.Unique

  • cats.mtl.Raise

  • cats.mtl.Stateful

Plus we need a cats.Monad instance for our effect container F[_], so that we can construct a monadic for-comprehension, as seen above.

What could be our effect container F[_]?

  • we need to be able to access our capability implementations from it

  • we need the effect container to be a monad

  • we need to be able to make it a type constructor of kind * -> *

The cats.data.Reader monad perfectly fits the above requirements, so let's use that!

Thus, we can "substitute" F[_] with [A] =>> Reader[EF, A]. A is the type of effectful computation, the result of our above program (in our case String), wrapped in our effect container Reader. EF is the type of some read-only value we can get from the environment, from our effect container Reader. In our case, ef: EF needs to have some methods like ef.unique, ef.raise and ef.stateful defined.

With a general plan laid out, let's start by providing the typeclass instance for (arguably) the most straightforward effect:

We need to create a new YAES capability that will match the Cats Effect for generating a unique token since YAES capabilities perform their effect directly, "outside" the effect container. YAES capabilities are non-monadic, their computation does not depend on previous operations from the effect container, nor do they alter the behavior of the effect container for follow-up computations. To avoid name-clashes with the existing cats trait Unique, we'll call our YAES trait TokenGen.

Putting the above points into action, creating an effect typeclass instance for Unique by delegating the work to a TokenGen capability, which is part of environment context EF via HasTokenGen:

Let's construct a simple program, substituting F[_] with our Reader monad. This is just a description of a program, it's not run until we give it the required capabilities:

We now need to give it the implementation of the capability TokenGen, wrapped in our environment context HasTokenGen, in order to run this program description:

However, this is not entirely in the spirit of YAES, and its idiomatic way to run capabilities by providing context parameters. Let's try to rewrite this in YAES's spirit then:

We have successfully crossed the bridge between YAES-like capabilities and TF-style applications, at least for this simple program. Yay!

Let's attempt a similar approach for the capability MutState[S] that will be used as the counterpart for the effect Stateful[F[_], S]:

  • Instead of Reader[EF, A] we will be using State[SF, A] as our effect container for F[_]

    • sf: SF stays the same as ef: EF before, bar the name change

  • This allows us to make MutState immutable, and update it using the State monad instead

  • We need this in order to satisfy the laws for the Stateful effect

  • We'll use a local variable inside MutState.run, in order to keep track of the final MutState[S].value

    • refer to the end of the [#state-threading](state-threading section) for more details on this approach

    • we are adding an additional private method MutState.withValue, to make the creation of a new MutState atomic

Testing the new MutState capability:

After migrating TokenGen to the State monad, and following this pattern going forward, we can create similar bridges between other capabilities and effects. You can find the whole runnable code in the other file of this gist, Scastie.

One more interesting thing is how you can build and run the program from the start of this chapter, after putting everything together:

Closing thoughts

I hope my thoughts and examples here have given at least some food for thought on using and integrating YAES into existing applications. It seems like a lightweight alternative to a monadic effect system and a step towards more direct-style effect handling.

One of the advantages here of using YAES instead of cats for the program above is that we have gotten rid of the whole monad transformer stack that would have been used for the effect container F[_]! We are using a simple State monad for F[_] instead. There have been reports of performance degradation due to deeply nested monad stacks. Furthermore, the compiler's type inference is getting more tricky, and may need additional type hints the more complex the stack gets.

Moreover, Monads to not compose in all scenarios, you cannot make a monad transformer for all combinations of all kinds of monads. However, I'm also quite not sure whether YAES capabilities can compose properly either, in all scenarios. This would be an awesome boon if true.

Furthermore, as we have seen above, some compromises have to be made in terms of referential transparency when trying to thread state context throughout the call stack.

It’s also possible that I’ve overlooked a critical detail related to state threading or the integration of YAES into tagless final–style code, but these are the key challenges I’ve identified so far.

For full context, check out the original version of this blog post. Interestingly, the author of YAES, Riccardo Cardin, noted in the comments that this exploration has pushed the library further than he had imagined, a nice encouragement to continue evolving this space.

Spread the word:
Keep readingSimilar blogs for further insights
Spring Data REST: Simplify Java Development with Automated RESTful APIs
Technology
Tomislav B.6 min readJun 4, 2025
Spring Data REST: Simplify Java Development with Automated RESTful APIsSpring Data REST is a powerful framework that speeds up Java development by automatically generating RESTful APIs from Spring Data repositories. This blog explores its key features, provides practical examples, and offers best practices for efficient API development.
How to Successfully Upgrade a Massive Rails App from 4.2 to 6.0
Technology
Jerko Č.10 min readMay 28, 2025
How to Successfully Upgrade a Massive Rails App from 4.2 to 6.0Everyone gave up on upgrading this Rails 4.2 project until a clean-slate strategy finally cracked it. A real-world method that now works every single time.
Beginner’s Guide to Role-Based Access Control in Spring Boot
Technology
Vladimir Š.4 min readMay 21, 2025
Beginner’s Guide to Role-Based Access Control in Spring BootThis blog explains how to secure a Spring Boot application using Role-Based Access Control (RBAC) with Spring Security. You'll learn to configure role-specific endpoints, manage in-memory users, and explore ways to enhance security using JWT, database authentication, and method-level annotations.