Your Program Is a Language

By Ryan Peters

@ 47 Degrees

About Me

  • Senior Scala Engineer at 47 Degrees
  • Writing Scala since 2017, professionally since 2018
  • Not exactly an academic - BS in CompSci, minor in Mathematics

Some Notes

  • Code is in Scala 3
  • Any demoed code also has a Scala 2 version
  • Talk is intermediate level (not explaining everything)
  • Talk was pre-recorded

Part 1

What We Talk About When We Talk About DSLs

  • Domain-Specific - Relating to a particular field or problem
  • Language - A set of rules and expressions for communication

"Increase balance of account X by 500 cents"

Describes what you want to do, less-so how

Low Coupling

Modules should not interdepend on each other

High Cohesion

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"

POP QUIZ

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
				

From Akka Streams documentation


body(
  h2("My Header"),
  p("Paragraph of Text"),
	ul(
		li("List item 1"),
		li("List item 2"),
	)
)
				

Scalatags HTML generation

Type Classes might as well be DSLs


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)

Part 2

Styles of DSLs

Embedded

  • Using a "host language"
  • Syntax, capabilities limited
  • Very little work

External / Parsed (text)

  • Can use any syntax
  • More portable
  • More work

Syntax in Scala 3


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)
				

Data Structure DSL


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))
)
				

Free DSL

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.

Tagless Final DSL


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"

Data/Free VS Tagless

Data/Free

  • Pretty easy to teach
  • More boilerplate to lift to Free
  • Runs into "expression problem"

Tagless

  • Can use arbitrary effects
  • Less indirection / lower overhead
  • Does not have "expression problem"

Other reasons I like Tagless Final

  • Very, very common in Scala
  • Easy to extend in multiple directions
  • The most lightweight
  • More familiar to OOP programmers
  • Can do everything* others can do + more
  • You can combine multiple DSLs in a program

*Except macros, technically

The Expression Problem

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)

Using an ADT


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

Using Traits


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

Why not both?


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...
				

Part 3

Code Examples

On GitHub @ sloshy/scalacon-dsl-examples

Part 4

DSL Design Technique

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."

The wrong way

  1. Deciding on an overall system architecture
  2. Looking at implementation details for your account database
  3. Building anything with premature optimization

The right way

  1. Creating types for every concept (money, account, event)
  2. Modeling the relationships in the requirements as a DSL
  3. Letting the compiler tell you it all works before you implement an interpreter/compiler

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)
				

Takeaways

  • DSLs are any code that makes business requirements easier to express
  • External DSLs are better for portability between languages (e.g. HTML, SQL)
  • Embedded DSLs are typically best done in tagless final style (least trade-offs)
  • Write your code from the top-down, starting with requirements first

Other Resources

Thank you!