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:
@diesel
to make it easier to compose algebras together.@ktrans
to make it easier to perform Kind transforms on interpreters.
To use diesel in your project, add it to your build.
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).
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:
- examples/KVSApp: a simple single-DSL program
- examples/KVSLoggingApp: composing 2 DSLs in a program
- 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 :)
@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
}
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).
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.
@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))
}
}
}
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.
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"
Learnt quite a lot about tagless final from the following resources.