The False Hope of Managing Effects With Tagless-Final in Scala (Part 1)
Check out this first installment on using tagless-final in Scala.
Join the DZone community and get the full member experience.
Join For FreeTagless-final is a technique originally used to embed domain-specific languages into a host language, without the use of Generalized Algebraic Data Types.
In the Haskell community, tagless-final still refers to a way of creating polymorphic programs in a custom DSL that are interpreted by instantiating them to a concrete data type. In the Scala community, however, tagless-final is used almost exclusively for monadic, effectful DSLs. Usage of the term in Scala is closest to what Haskeller’s mean by MTL-style, but without the algebraic laws that govern MTL type classes.
In Scala, tagless-final has become almost synonymous with types of kind * -> *
, leading to the infamous F[_]
higher-kinded type parameter that is so pervasively associated with the phrase tagless-final.
In this series, I will argue that the many claims made about tagless-final in Scala are not entirely true and that the actual benefits of tagless-final come mostly from discipline, not from so-called effect polymorphism.
After this detailed analysis, I will conclude the series by providing a list of concrete recommendations for developers who are building functional Scala libraries and applications. Let's get started.
Tagless-Final 101
In Scala, tagless-final involves creating type classes, which describe capabilities of a generic effect F[_]
.
Note: There is an alternate, and I’d argue superior, encoding of tagless-final that involves not type classes but effect-polymorphic interfaces, which are passed as ordinary parameters; but this alternate encoding doesn’t substantially change my arguments, so I won’t discuss it here.
For example, we can create the following type class to describe console-related capabilities of some effect F[_]
:
trait Console[F[_]] {
def putStrLn(line: String): F[Unit]
val getStrLn: F[String]
}
Or we could create the following type class to describe persistence capabilities for User
objects:
trait UserRepository[F[_]] {
def getUserById(id: UserID): F[User]
def getUserProfile(user: User): F[UserProfile]
def updateUserProfile(user: User, profile: UserProfile): F[Unit]
}
These type classes allow us to create methods that are polymorphic in the effect type F[_]
but have access to the required capabilities. For example, we could describe a program that uses the Console
interface as follows:
def consoleProgram[F[_]: Console]: F[Unit] =
implicitly[Console[F]].putStrLn("Hello World!")
Combined with type classes like Monad
(which allows chaining effects), we can build entire programs using the tagless-final approach:
def consoleProgram[F[_]: Console: Monad]: F[String] = {
val console = implicitly[Console[F]]
import console._
for {
_ <- putStrLn("What is your name?")
name <- getStrLn
_ <- putStrLn(s"Hello, $name, good to meet you!")
} yield name
}
Because such programs are polymorphic in the effect type F[_]
, we can instantiate these polymorphic programs to any concrete effect type that provides whatever they require.
For example, if we are using ZIO Task
(a type alias for ZIO[Any, Throwable, A]
), we can instantiate our program to this concrete effect type with the following code snippet:
val taskProgram: Task[String] = consoleProgram[Task]
Typically, the instantiation of a polymorphic tagless-final value to a concrete effect type is deferred as long as possible, preferably to the entry points of the application or test suite.
With this introduction, let’s talk about the reasons you might want to use tagless-final…the pitch for the tagless-final technique, if you will.
The Pitch for Tagless-Final
Tagless-final has a seductive pitch that appeals to every aspiring functional programmer.
In the functional programming community, we are taught (with good reason) that monomorphic code can’t be reused much, and that it leads to more bugs.
We are taught that generic code not only enables reuse, but it pushes more information into the types, where the compiler can help us verify and maintain correctness.
We are taught the principle of least power, which tells us that our functions should require as little as necessary to do their job.
I have helped develop, motivate, and teach these and other principles in my Functional Scala workshops, helping train new generations of Scala developers in the functional way of thinking and developing software.
In this context, functional programmers are primed for the tagless-final pitch; I know this because I have given the tagless-final pitch, and even helped craft its modern day incarnation.
In one video, I unintentionally convinced several companies to adopt tagless-final, despite an explicit disclaimer stating the techniques would be overkill for many applications!
In the next few sections, I’m going to give you this pitch and try to convince you that tagless-final is the best thing ever. Moreover, I’m going to use only arguments that have an element of truth.
Ready? Here we go!
1. Effect Type Indirection
As of this writing, there are several mainstream effect types, including ZIO, Monix, and Cats IO, all of which ship with Cats Effect instances and can be used more or less interchangeably in libraries like FS2, Doobie, and http4s.
Tagless-final lets you insulate your code from the decision of which effect type to use. Rather than pick one of these concrete implementations, using tagless-final lets you write effect type-agnostic code, which can be instantiated to any concrete effect type that provides Cats Effect instances.
For example, our preceding console program can just as easily be instantiated to Cats IO:
val ioProgram = consoleProgram[cats.effect.IO]
With tagless-final, you can defer the decision of which effect type to use indefinitely (or at least, to the edge of your program), isolating your application from changes in an evolving ecosystem.
Tagless-final lets you future-proof your code!
2. Effect Testability
Tagless-final, because it provides a strong layer of indirection between your application, and the concrete effect type that models effects, enables your code to be fully testable.
In the preceding console implementation, it is easy to define a test instance of the Console
type class:
final case class ConsoleData(input: List[String], output: List[String])
final case class ConsoleTest[A](run: ConsoleData => (ConsoleData, A)) {
def putStrLn(line: String): ConsoleTest[Unit] =
ConsoleTest(data => (data.copy(output = line :: data.output), ()))
val getStrLn: ConsoleTest[String] =
ConsoleTest(data => (data.copy(input = data.input.drop(1)), data.input.head))
}
Now, assuming we define an appropriate Monad
instance for our data type (which is easy to do!), we can instantiate our polymorphic consoleProgram
to the new type:
val testProgram = consoleProgram[ConsoleTest]
Tada! We can now unit test our console program with fast, deterministic unit tests, thereby reaping the full testability benefits of pure functional programming.
3. Effect Parametric Reasoning
Parametric reasoning in statically-typed functional programming circles refers to the ability for us to reason generically about the implementation of a polymorphic function merely by looking at its type.
For example, there is one possible implementation of the following function:
def identity[A](a: A): A = ???
(Assuming no reflection, exceptions, or use of null
.)
Parametric reasoning allows us to save time when we are studying code. We can look at the types of a function, and even if we don’t know the exact implementation, we can place bounds on what the function can do.
Parametric reasoning lets us more quickly and more reliably understand code bases, which is critical for safe maintenance of those code bases in response to new and changing business requirements.
Moreover, parametric reasoning can reduce the need for unit testing: whatever is guaranteed by the type, does not need to be tested by unit tests. Types prove universal properties across all values, so they are strictly more powerful than tests, which prove existential properties across a few values.
Since tagless-final is an example of (higher-kinded) parametric polymorphism, we can leverage parametric polymorphism for effect types too.
For example, take the following code snippet:
def consoleProgram[F[_]: Console: Applicative]: F[String] = ???
Although we don’t know what the implementation of this function is without looking, we know that it requires F[_]
to provide both Console
and Applicative
instances.
Because F[_]
is only Applicative
and not Monad
, we know that although consoleProgram
can have a sequential chain of console effects, no subsequent effect can depend on the runtime value of a predecessor effect (that capability would require bind
from Monad
).
We would not be surprised if we saw the implementation were as follows:
def consoleProgram[F[_]: Console: Applicative]: F[String] = {
val console = implicitly[Console[F]]
import console._
putStrLn("What is your name?") *> getStrLn
}
By constraining the capabilities of the data type F[_]
, tagless-final lets us reason about our effectful programs parametrically, which lets us spend less time studying code, and less time writing unit tests.
The Closer
As we have seen, tagless-final is an incredible asset to functional Scala programmers everywhere.
Not only does it enable abstraction over effects, so programmers can change their mind about which effect type to use, but the technique gives us full testability of effectful programs, and helps us reason about our effectful programs in new ways, which can reduce the need for studying the code and cut down on unit tests.
We hope you enjoyed this first installment. For Part 2 where we look at the fine print of tagless-final — stay tuned!
Published at DZone with permission of John De Goes, DZone MVB. See the original article here.
Opinions expressed by DZone contributors are their own.
Comments