Final tagless. The topic strikes fear into the hearts of Scala developers everywhere—and not without reason. Final tagless allows developers to build composable Domain Specific Languages (DSLs) that model interaction with the outside world. Programs written using the final tagless style can be tested deterministically and reasoned about at compile-time. Yet the technique requires confusing, compiler-choking higher-kinded types, like `F[_]`, and pervasive, non-inferable context bounds like `F[_]: Concurrent: Console: Logging`. Many have looked at final tagless and wondered if all the layers of complexity and ceremony are really worth the benefits.
In this presentation, John A. De Goes provides a gentle and accessible introduction to final tagless, explaining what it is and the problem it intends to solve. John shows that while final tagless is easier to use than free monads, the technique suffers from a litany of drawbacks that push developers away from functional programming in Scala. John then introduces a novel approach that shares some of the benefits of final tagless, but which is idiomatic Scala, easy to explain, doesn’t need any complex type machinery, provides flawless type inference, and works beautifully across Scala 2.x and Scala 3.
Come join John for an evening of fun as you learn how to write functional code in Scala that's easy to test and easy to reason about—all without the complexity of free monads or final tagless.
10. BENEFITS OF VALUES
def main: Unit = {
println("Good morning, what is your name?")
val name = readLine()
println(s"Good to meet you, $name!")
}
Procedural
Effects
Functional
Effects
11. Functional effects are immutable data structures
that merely describe sequences of operations.
At the end of the world, the data structure has to
be impurely “interpreted” to real world effects.
14. PROGRAMS AS VALUES
IO[A]
A description of an effect that when unsafely
interpreted, will succeed with a value of type A
15. PROGRAMS AS VALUES
class IO[+A](val unsafeInterpret: () => A) { s =>
def map[B](f: A => B) = flatMap(f.andThen(IO.effect(_)))
def flatMap[B](f: A => IO[B]): IO[B] =
IO.effect(f(s.unsafeInterpret()).unsafeInterpret())
}
object IO {
def effect[A](eff: => A) = new IO(() => eff)
}
16. PROGRAMS AS VALUES
def putStrLn(line: String): IO[Unit] =
IO.effect(println(line))
val getStrLn: IO[String] =
IO.effect(scala.io.StdIn.readLine())
17. PROGRAMS AS VALUES
val main = for {
_ <- putStrLn("Good morning, " +
"what is your name?")
name <- getStrLn
_ <- putStrLn(s"Good to meet you, $name!")
} yield ()
18. PROGRAMS AS VALUES
val main = …
val program = main
val programs = List.fill(10)(main)
val retried = program =>
program.retried(2.times)
19. PROGRAMS AS VALUES
def loadTest(url: String, n: Int) =
val policy = Schedule.recurs(10).jittered
val worker = client.get(url).retry(policy)
val workers = List.fill(n)(worker)
IO.collectAllPar(workers)
}
20. PROGRAMS AS VALUES
def loadTest(url: String, n: Int) =
val policy = Schedule.recurs(10).jittered
val worker = client.get(url).retry(policy)
val workers = List.fill(n)(worker)
IO.collectAllPar(workers)
}
21. PROGRAMS AS VALUES
def loadTest(url: String, n: Int) =
val policy = Schedule.recurs(10).jittered
val worker = client.get(url).retry(policy)
val workers = List.fill(n)(worker)
IO.collectAllPar(workers)
}
22. PROGRAMS AS VALUES
def loadTest(url: String, n: Int) =
val policy = Schedule.recurs(10).jittered
val worker = client.get(url).retry(policy)
val workers = List.fill(n)(worker)
IO.collectAllPar(workers)
}
28. BACK TO BASICS: JAVA 101
interface Console {
void println(String line);
String readLine();
}
29. BACK TO BASICS: JAVA 101
public void program(Console console) {
console.println("Good morning, " +
"what is your name?");
String name = console.readLine();
console.println(s"Good to meet you, $name!");
}
30. BACK TO BASICS: JAVA 101
class LiveConsole implements Console { … }
class TestConsole implements Console { … }
// In production:
program(new LiveConsole());
// In tests:
program(new TestConsole());
33. PURE PROGRAM WITH INTERFACE
def program(c: Console) = for {
_ <- c.println("Good morning, " +
"what is your name?")
name <- c.readLine
_ <- c.println(s"Good to meet you, $name!")
} yield ()
35. MAKING IT EVEN MORE OBSCURE
trait Console[F[_]] {
def println(String line): F[Unit]
val readLine: F[String]
}
36. THE GLORIOUS TAGLESS-FINAL TYPE CLASS
trait Console[F[_]] {
def println(String line): F[Unit]
val readLine: F[String]
}
object Console {
def apply[F[_]](implicit F: Console[F]) = F
}
37. EFFECT-POLYMORPHIC PROGRAMS
def program[F[_]: Console: Monad] = for {
_ <- Console[F].println(
"Good morning, " +
"what is your name?")
name <- Console[F].readLine
_ <- Console[F].println(
s"Good to meet you, $name!")
} yield ()
38. TEST VS PRODUCTION EFFECTS
case class TestIO[A](...)
implicit val TestConsole = new Console[TestIO] { … }
implicit val LiveConsole = new Console[IO] { … }
// In production:
program[IO] : IO[Unit]
// In tests:
program[TestIO] : TestIO[Unit]
44. TAGLESS-FINAL ISN’T THAT HARD!
Functional Effects Parametric Polymorphism
trait Console[F[_]] { … }
Higher-Kinded Types Type Classes
45. TAGLESS-FINAL COULD BE MUCH HARDER!
Functional Effects Parametric Polymorphism
implicit val TestConsole = new Console[TestIO] { … }
Higher-Kinded Types Type Classes
Type Class Instances
46. TAGLESS-FINAL IS SORT OF HARD!
Functional Effects Parametric Polymorphism
new Console[({type F[A] = State[TestData, A]})#F] { … }
Higher-Kinded Types Type Classes
Type Class Instances Partial Type Application
47. OMG, TAGLESS-FINAL IS THE WORST!!!
Functional Effects Parametric Polymorphism
def program[F[_]: Monad: Console: Database] = ...
Higher-Kinded Types Type Classes
Type Class Instances Partial Type Application Monad Hierarchy
49. ☠ TYPE CLASS ABUSE ☠
THE DARK SIDE OF TAGLESS-FINAL
REAL TYPE CLASS
Defines common
structure across types
through lawful
operations, enabling
abstraction.
FAKE TYPE CLASS
Defines a common
interface across types
through ad hoc
polymorphism,
enabling testing.
50. ☠ TYPE CLASS ABUSE ☠
THE DARK SIDE OF TAGLESS-FINAL
REAL TYPE CLASS
Defines common
structure across types
through lawful
operations, enabling
abstraction.
FAKE TYPE CLASS
Defines a common
interface across types
through ad hoc
polymorphism,
enabling testing.
51. ☠ BIG BANG INTRODUCTION ☠
THE DARK SIDE OF TAGLESS-FINAL
def program[F[_]: Console: Monad] = for {
_ <- Console[F].println(
"Good morning, " +
"what is your name?")
name <- Console[F].readLine
_ <- Console[F].println(
s"Good to meet you, $name!")
} yield ()
52. ☠ TEDIOUS REPETITION ☠
THE DARK SIDE OF TAGLESS-FINAL
def genFeed[F[_]: Monad:
Logging: UserDatabase:
ProfileDatabase: RedisCache:
GeoIPService: AuthService:
SessionManager: Localization:
Config: EventQueue: Concurrent:
Async: MetricsManager]: F[Feed] = ???
53. ☠ STUBBORN REPETITION ☠
THE DARK SIDE OF TAGLESS-FINAL
def genFeed[F[_]: Everything]: F[Feed] =
???
61. BACK TO BASICS
trait Console {
def println(line: String): IO[Unit]
val readLine: IO[String]
}
62. BACK TO BASICS
def program(c: Console) = for {
_ <- c.println("Good morning, " +
"What is your name?")
name <- c.readLine
_ <- c.println(s"Good to meet you, $name!")
} yield ()
63. BACK TO BASICS
def program(c: Console, p: Persistence) = for {
_ <- c.println("Good morning, " +
"what is your name?")
name <- c.readLine
_ <- p.savePreferences(name)
_ <- c.println(s"Good to meet you, $name!")
} yield ()
64. BACK TO BASICS
def program(s1: Service1, s2: Service2,
s3: Service3, … sn: ServiceN) =
for {
a <- foo(s1, s9, s3)("localhost", 42)
b <- bar(sn, s19, s3)(a, 1024)
...
} yield z
65. BACK TO BASICS
def program(s1: Service1, s2: Service2,
s3: Service3, … sn: ServiceN) =
for {
a <- foo(s1, s9, s3)("localhost", 42)
b <- bar(sn, s19, s3)(a, 1024)
...
} yield z
66. THE MODULE PATTERN
trait HasConsole {
def console: HasConsole.Service
}
object HasConsole {
trait Service {
def println(line: String): IO[Unit]
val readLine: IO[String]
}
}
67. THE MODULE PATTERN
trait HasConsole {
def console: HasConsole.Service
}
object HasConsole {
trait Service {
def println(line: String): IO[Unit]
val readLine: IO[String]
}
}
68. THE MODULE PATTERN
def program(s: HasConsole with HasPersistence) =
for {
_ <- s.console.println("What is your name?")
name <- s.console.readLine
_ <- s.persistence.savePreferences(name)
_ <- s.console.println(s"Good to meet” +
” you, $name!")
} yield ()
69. THE MODULE PATTERN
def program(s: HasService1 with … HasServiceN) =
for {
a <- foo(s)("localhost", 42)
b <- bar(s)(a, 1024)
...
} yield z
70. THE READER MONAD
case class Reader[-R, +A](provide: R => A) { self =>
def map[B](f: A => B) = flatMap(a => Reader.point(f(a)))
def flatMap[R1 <: R, B](f: A => Reader[R1, B]) =
Reader[R, B](r => f(self.provide(r)).provide(r))
}
object Reader {
def point[A](a: => A): Reader[Any, A] = Reader(_ => a)
def environment[R]: Reader[R, R] = Reader(identity)
}
71. THE READER MONAD
def program: Reader[HasService1 with ..., Unit] =
for {
a <- foo("localhost", 42)
b <- bar(a, 1024)
...
} yield z
84. ZIO ENVIRONMENT: EXAMPLE
val program: ZIO[Console with Persistence, IOException, Unit] =
for {
_ <- putStrLn("Good morning, what is your name?")
name <- getStrLn
_ <- savePreferences(name)
_ <- putStrLn(s"Good to meet you, $name!")
} yield ()
85. ZIO ENVIRONMENT: CORE
sealed trait ZIO[-R, +E, +A] {
...
def provide(environment: R): ZIO[Any, E, A] = ...
}
object ZIO {
def accessM[R, E, A](f: R => ZIO[R, E, A]): ZIO[R, E, A] = ...
def access[R, E, A](f: R => A): ZIO[R, Nothing, A] =
accessM(ZIO.succeed(_))
def environment[R]: ZIO[R, Nothing, R] = access(identity)
}
86. ZIO ENVIRONMENT: CORE
sealed trait ZIO[-R, +E, +A] {
...
def provide(environment: R): ZIO[Any, E, A] = ...
}
object ZIO {
def accessM[R, E, A](f: R => ZIO[R, E, A]): ZIO[R, E, A] = ...
def access[R, E, A](f: R => A): ZIO[R, Nothing, A] =
accessM(ZIO.succeed(_))
def environment[R]: ZIO[R, Nothing, R] = access(identity)
}
87. ZIO ENVIRONMENT: CORE
sealed trait ZIO[-R, +E, +A] {
...
def provide(environment: R): ZIO[Any, E, A] = ...
}
object ZIO {
def accessM[R, E, A](f: R => ZIO[R, E, A]): ZIO[R, E, A] = ...
def access[R, E, A](f: R => A): ZIO[R, Nothing, A] =
accessM(ZIO.succeed(_))
def environment[R]: ZIO[R, Nothing, R] = access(identity)
}
88. ZIO ENVIRONMENT: CORE
sealed trait ZIO[-R, +E, +A] {
...
def provide(environment: R): ZIO[Any, E, A] = ...
}
object ZIO {
def accessM[R, E, A](f: R => ZIO[R, E, A]): ZIO[R, E, A] = ...
def access[R, E, A](f: R => A): ZIO[R, Nothing, A] =
accessM(ZIO.succeed(_))
def environment[R]: ZIO[R, Nothing, R] = access(identity)
}
93. ZIO ENVIRONMENT: TUTORIAL
val program: ZIO[Console, IOException, Unit] =
for {
_ <- putStrLn("Good morning, what is your name?")
name <- getStrLn
_ <- putStrLn(s"Good to meet you, $name!")
} yield ()
DefaultRuntime.unsafeRun(program.provide(ConsoleLive))
95. ZIO ENVIRONMENT: TUTORIAL
val MyEnvironment: R = ...
val MyRuntime: Runtime[R] =
Runtime(MyEnvironment, PlatformLive)
Your Own R Platform (thread pool, etc.)
101. ZIO ENVIRONMENT: FULLY INFERABLE
val program =
for {
_ <- putStrLn("Good morning, what is your name?")
name <- getStrLn
_ <- savePreferences(name)
_ <- log.debug("Saved $name to configuration")
_ <- putStrLn(s"Good to meet you, $name!")
} yield ()
102. ZIO ENVIRONMENT: FULLY INFERABLE
val program: ZIO[Console, Error, Unit] = ...
^^^^^^^
Found: Console
Expected: Console with Persistence with Logging with
Config with Auth
113. THE (RE)RISING OF CAKE
// Traditional MONOLITHIC cake
trait MyModule extends
CacheModule with LoggingModule with
DatabaseModule with AuthModule {
def refreshCache: Unit = ...
def genFeed : Feed = ...
def trace : Unit = ...
}
114. THE (RE)RISING OF CAKE
// Next-generation MODULAR cake
object MyFunctions {
val refreshCache: ZIO[Cache, Error, Unit] = ...
val genFeed: ZIO[Database with Auth, Error, Feed] = ...
val trace: ZIO[Debug, Nothing, Unit] = ...
}