Skip to content

Boilerplate-free, zero-overhead Tagless Final / typed-final / Finally Tagless DSLs in Scala

License

Notifications You must be signed in to change notification settings

lloydmeta/diesel

Repository files navigation

Diesel Build Status Maven Central Scala.js Join the chat at https://gitter.im/diesel-k/Lobby

diesel ˈdiːz(ə)l

  • Boilerplate-free Tagless Final DSLs via macro annotations (Scastie demo), written in scala.meta for future compatibility and other nice things (e.g. free IDE support, like in IntelliJ)
  • What "DSL" sounds like when you say it five times fast
  • More torque → more fun

This library provides 2 macros:

  1. @diesel to make it easier to compose algebras together.
  2. @ktrans to make it easier to perform Kind transforms on interpreters.

To use diesel in your project, add it to your build.

@diesel

The @diesel annotation cuts out the boilerplate associated with writing composable Tagless Final DSLs.

Your DSL can be accessed directly from the companion object if you import a converter located in ops (customisable by passing a name to the annotation as an argument) and you have an implementation of your DSL in scope. This is useful when you need to compose multiple DSLs in the context of F[_], but do not want to name all the interpreter parameters.

Note that this can be used along-side @ktrans (simply write the annotations next to each other).

Example:

import diesel._, cats._, cats.implicits._

object DieselDemo {

  // Declare your DSL
  @diesel
  trait Maths[F[_]] {
    def times(l: Int, r: Int): F[Int]
    def add(l: Int, r: Int): F[Int]
  }

  @diesel
  trait Logger[F[_]] {
    def info(s: String): F[Unit]
  }

  // Import companion-to-interpreter aliasing sugar
  import Maths.ops._, Logger.ops._

  def prog[F[_]: Monad: Maths: Logger](x: Int, y: Int): F[Int] = {
    for {
      p <- Maths.times(x, y)
      _ <- Logger.info(s"Product: $p")
      s <- Maths.add(x, y)
      _ <- Logger.info(s"Sum: $s")
      f <- Maths.add(p, s)
      _ <- Logger.info(s"Final: $f")
    } yield f
  }

  def main(args: Array[String]): Unit = {

    // Wire our interpreters in
    implicit val mathsInterp = new Maths[Id] {
      def times(l: Int, r: Int) = l * r
      def add(l: Int, r: Int)   = l + r
    }
    implicit val loggingInterp = new Logger[Id] {
      def info(msg: String) = println(msg)
    }

    val _ = prog[Id](3, 4)
  }

}
/*
[info] Running DieselDemo
Product: 12
Sum: 7
Final: 19
*/

For more in-depth examples, check out:

  1. examples/KVSApp: a simple single-DSL program
  2. examples/KVSLoggingApp: composing 2 DSLs in a program
  3. examples/FibApp: composing 3 DSLs in a program that calculates fibonacci numbers and caches them.

All of the above examples use a pure KVS interpreter :)

How it works

@diesel
trait Maths[F[_]] {
  def times(l: Int, r: Int): F[Int]
  def add(l: Int, r: Int): F[Int]
}

is expanded approximately into

// Your algebra. Implement by providing a concrete F and you have your interpreter
trait Maths[F[_]] {
    def times(l: Int, r: Int): F[Int]
    def add(l: Int, r: Int): F[Int]
}

// Helper methods will be added to the algebra's companion object (one will be created if there isn't one yet)
object Maths {

  /**
    * In charge of aliasing your singleton Maths object to an in-scope Maths[F].
    * Simply `import Maths.ops._` :)
   **/
  object ops {
    implicit def toDsl[F[_]](o: Maths.type)(implicit m: Maths[F]): Maths[F] = m
  }

  /**
    * For when you feel like calling an in-scope interpreter via
    * `Maths[F]` syntax. Also helpful when you want to import all
    * the DSL methods using something like
    *
    * ```
    * val m = Maths[F]
    * import m._
    * add(1, 3)
    * ```
   **/
  def apply[F[_]](implicit m: Maths[F]): Maths[F] = m
}

@ktrans

The @ktrans annotation adds a transformK method (customisable by passing a name to the annotation as an argument) to a trait/abstract class that is parameterised by a Kind that takes 1 type parameter. It's handy when you want to transform any given implementation of that trait on F[_] into one that implements it on G[_].

Useful because it saves you from having to write boilerplate (method forwarding and results wrapping) that you would otherwise need to deal with manually (or via an IDE) when instantiating a new trait/abstract class.

Note that this can be used along-side @diesel (simply write the annotations next to each other).

Example

import diesel._, cats._
import diesel.implicits._

@ktrans
trait Maths[G[_]] {
  def add(l: Int, r: Int): G[Int]
  def subtract(l: Int, r: Int): G[Int]
  def times(l: Int, r: Int): G[Int]
}

val MathsIdInterp = new Maths[Id] {
  def add(l: Int, r: Int)      = l + r
  def subtract(l: Int, r: Int) = l - r
  def times(l: Int, r: Int)    = l * r
}

// Using kind-project syntax to build our natural transformation from
// Id to Option
val idToOpt = λ[FunK[Id, Option]](Some(_))

// use the auto-generated transformK method to create a Maths[Option] from Maths[Id]
// via idToOpt
val MathsOptInterp = MathsIdInterp.transformK(idToOpt)

assert(MathsOptInterp.add(3, 10) == Some(13))

There are conversions from Cat's natural transformation (FunctionK) or Scalaz's NaturalTransformation to FunK in the diesel-cats and diesel-scalaz companion projects.

How it works

@ktrans
trait Maths[G[_]] {
  def add(l: Int, r: Int): G[Int]
  def subtract(l: Int, r: Int): G[Int]
  def times(l: Int, r: Int): G[Int]
}

is roughly expanded into

trait Maths[G[_]] {
  def add(l: Int, r: Int): G[Int]
  def subtract(l: Int, r: Int): G[Int]
  def times(l: Int, r: Int): G[Int]

  // Note that FunK is a really simple NaturalTransform / FunctionK
  final def transformK[H[_]](natTrans: FunK[G, H]): Maths[H] = {
    val curr = this
    new Maths[H] {
      def add(l: Int, r: Int): H[Int]      = natTrans(curr.add(l, r))
      def subtract(l: Int, r: Int): H[Int] = natTrans(curr.subtract(l, r))
      def times(l: Int, r: Int): H[Int]    = natTrans(curr.times(l, r))
    }
  }
}

Limitations

Because the @ktrans annotation's goal is to generate a kind-transformation method for your trait, it is subject to a few limitations:

  • Annottee must be parameterised by a higher kinded type with just 1 type parameter (context bounds allowed though)
  • No unimplemented methods that return types parameterised by the kind parameter of the algebra
  • No unimplemented type members
  • No vals that are not assignments

A lot of attention (in fact most of the code in the macro) has been dedicated to outputting understandable compile-time errors for those cases, so please reach out if anything seems amiss.

Sbt

Maven Central

Diesel is published for Scala 2.11, 2.12 and ScalaJS.

libraryDependencies += "com.beachape" %% "diesel-core" % s"$latest_version"

// Additional ceremony for using Scalameta macro annotations

resolvers += Resolver.url(
  "scalameta",
  url("http://dl.bintray.com/scalameta/maven"))(Resolver.ivyStylePatterns)

// A dependency on macro paradise is required to both write and expand
// new-style macros.  This is similar to how it works for old-style macro
// annotations and a dependency on macro paradise 2.x.
addCompilerPlugin(
  "org.scalameta" % "paradise" % "3.0.0-M8" cross CrossVersion.full)

scalacOptions += "-Xplugin-require:macroparadise"

Credit

Learnt quite a lot about tagless final from the following resources.

  1. Free vs Tagless final talk
  2. Alternatives to GADTS in Scala
  3. Quark talk
  4. Tagless final effects à la Ermine writers
  5. EDSLs as functions