Describes what you want to do, less-so how
Modules should not interdepend on each other
Modules should contain closely-related code
DSLs are often, not always, interpreted or "compiled". Henceforth we'll use "interpreter/compiler" interchangeably.
(Remember when I said those were the same?)
Interpretation means indirection. Indirection means writing more generic, abstract code that can be repurposed.
This is the essence of what makes a "good DSL"
What is the difference between normal library code and a DSL?
(trick question; there isn't one)
A good DSL is anything that is used to model your business requirements directly.
DSLs themselves can have a high/low hierarchy as well.
Example from http4s
GET -> Root / "user" / IntValue(userId)
Matches a GET request at path "/user/(userId)" where userId is an Int
val in = Source(1 to 10)
val out = Sink.ignore
val bcast = builder.add(Broadcast[Int](2))
val merge = builder.add(Merge[Int](2))
val f1, f2, f3, f4 = Flow[Int].map(_ + 10)
in ~> f1 ~> bcast ~> f2 ~> merge ~> f3 ~> out
bcast ~> f4 ~> merge
ClosedShape
body(
h2("My Header"),
p("Paragraph of Text"),
ul(
li("List item 1"),
li("List item 2"),
)
)
Scalatags HTML generation
trait Monad[F[_]]:
//Creates an F[B] based on the value of F[A]
def flatMap[A, B](fa: F[A])(f: A => F[B]): F[B]
Monad is like a "DSL for dependent computations"
DSLs and Type Classes encode "capability"
"What operations are legal? What concepts can I represent?"
It makes sense that a lot of DSLs are written as type classes (tagless final style)
opaque type Natural = Int
object Natural:
def fromAbsOf(i: Int): Natural = i.abs
opaque type HighScore = Int
object HighScore:
def fromNat(n: Natural): HighScore = n
extension (hs: HighScore)
def >(that: HighScore): Boolean = hs > that
def <(that: HighScore): Boolean = hs < that
def +(that: HighScore): HighScore = hs + that
def +(that: Natural): HighScore = hs + that
infix def max(that: HighScore): HighScore = hs.max(that)
enum Transformation:
case ToLowerCase
enum CustomDSL[A]:
case Print(s: String) extends CustomDSL[Unit]
case DoNext[A](a: CustomDSL[Any], next: CustomDSL[A]) extends CustomDSL[A]
case ReadInput extends CustomDSL[String]
case Transform(a: CustomDSL[String], t: Transformation) extends CustomDSL[String]
def compiler(program: CustomDSL[A]): cats.Id[A] = ???
compiler(
DoNext(Print("Enter your name"), Transform(ReadInput, Transformation.ToLowerCase))
)
Based on previous code
import cats.free.Free
type DSLProgram[A] = Free[CustomDSL, A]
def print(s: String): DSLProgram[Unit] =
Free.liftF(CustomDSL.Print(s)) //And so on
//Don't need DoNext
val freeProgram = print("Hello") >> print("World")
val result = freeProgram.foldMap(compiler)
Free gives us a Monad "for free" that allows us to sequence our ops.
Also see Free Applicative for independent computations.
trait CustomDSL[F[_]]:
def print(s: String): F[Unit]
def readInput: F[String]
def transform(s: String, t: Transformation)
given CustomDSL[cats.Id] with //Our compiler
def print(s: String) = println(s) //And so on
def program[F[_]: cats.Applicative](using dsl: CustomDSL[F]) =
import dsl._
print("Enter your name") *>
readInput.map(transform(_, Transformation.ToLowerCase))
val compiledProgram: String = program[Id] //Id == no effect
A tagless final DSL is often called an "algebra"
*Except macros, technically
A common* issue when writing any DSL is you lose some freedom of expression.
Ideally we want DSLs that are easily extended and without harsh compromises.
*(this actually came up while I was writing examples)
sealed trait StringExpr
enum Expr:
case Literal(s: String) extends Expr with StringExpr
case Print(lit: StringExpr) extends Expr
case Read extends Expr with StringExpr
You can't add new operations to this ADT DSL without changing it
def compilerOne(e: Expr): Unit
def compilerTwo(e: Expr): IO[Unit]
However, it's very easy to add new compilers with very different implementations
trait MyDSL:
def print(s: String): Unit
def read: String
trait MyLargerDSL extends MyDSL:
def readFromFile(path: Path): Try[String]
def writeToFile(s: String, path: Path): Try[Unit]
You can add new operations by extending the previous trait
class MyDSLImpl() extends MyDSL:
def print(s: String): Unit = println(s)
def read: String = Console.in.readLine()
class MyOtherDSLImpl() extends MyDSL:
//Types don't give us a lot of options for interpreting the DSL?
//i.e. can't use Try, Future, IO
def print(s: String): Unit = ???
def read: String = ???
...But it's a lot more restrictive in terms of how you actually implement things
import cats.effect.std.Console
import fs2.io.files.{Files, Path}
trait MyDSL[F[_]]:
def print(s: String): F[Unit]
def read: F[String]
trait MyExtendedDSL[F[_]] extends MyDSL:
def readFromFile(path: Path): F[String]
def writeToFile(s: String, path: Path): F[Unit]
given [F[_]: Files: Console] MyExtendedDSL[F] with
def print(s: String) = Console[F].println(s)
//And so on...
On GitHub @ sloshy/scalacon-dsl-examples
The last thing you want is a solution in search of a problem
Instead of building up from a lower level base, build down from your top-level requirements
"A financial account has a monetary balance. The balance is affected at end-of-day. The balance consists of US Cents. At end-of-day, the balance is changed in response to particular events."
opaque type Cents = Int
opaque type BasisPoint = Int //1 == 0.01%
enum AccountEvent:
case IncreaseBalanceBy(amt: Cents)
case IncreaseInterestRateBy(amt: BasisPoint)
trait AccountHandler[F[_]]:
def modBalance(f: Cents => Cents): F[Unit]
def modInterestRate(f: BasisPoint => BasisPoint): F[Unit]
def eodHandler[F[_]](ev: AccountEvent, handler: AccountHandler[F]) =
ev match
case IncreaseBalanceBy(amt) => handler.modBalance(_ + amt)
case IncreaseInterestRateBy(amt) => handler.modInterestRate(_ + amt)