An internal DSL is a library whose call sites read like the problem domain. Scala is unusually good at them: methods can be operators, single-argument calls can drop dots and parentheses, blocks can carry an implicit context, and the type system can reject a malformed program before it runs. The same features make it easy to build a DSL nobody can read, debug or extend.

This article is about the design decisions, not about one library. We separate the two ways of embedding a language, go through the syntax tools and the precedence rules that decide what your users' expressions mean, build a context-function builder and a phantom-typed builder, and put it all together in a small alerting DSL with two interpreters. The snippets are Scala 3. Interpreter architecture at scale has its own pages: tagless final and free monads.

Advertisement

Shallow versus deep embedding

A shallow embedding gives every DSL construct its meaning immediately: a > 250 returns a Boolean, and the program is just ordinary Scala evaluation. A deep embedding makes every construct return a data structure, an abstract syntax tree, and leaves meaning to interpreters that walk the tree later.

One rule, two embeddingsUser codemetric(p99) > 250 && errors > 0.02Shallow embeddingoperators compute the answer nowDeep embeddingoperators build a Cond treeBooleanone meaning, fixed at design timeevalsample maprendertext formlintfind mistakesDeep: many interpreters, inspectable programs, more code.Shallow: least code, but the program cannot be analysed before it runs.
The same surface syntax can compute a value immediately or build a tree for several interpreters.
QuestionShallowDeep
How many meanings?OneAs many interpreters as you write
Can you inspect or optimise a program before running it?NoYes, it is data
Code to writeLeastADT plus one function per interpreter
Adding a constructAdd a functionAdd a case and update every interpreter (the compiler lists them)
Typical useTest assertions, small config helpersRules, queries, workflows, anything stored, diffed or rendered

Tagless final sits between the two: each construct is a method on an abstract algebra, so you get several interpreters without an explicit tree, but you cannot pattern-match on a program. If you need to lint, store or translate a program, choose deep. If you only ever run it, shallow is fine.

The syntax toolkit and the rules behind it

Scala gives a DSL designer five tools. Each comes with a rule your users will hit whether or not you know it.

ToolWhat it buysThe rule to respect
Symbolic methodsa > 250, x && yPrecedence comes from the first character of the name, not from your intent
Infix alphanumeric methodsa and bIn Scala 3, mark them infix; alphanumeric names share the lowest precedence
Extension methodsAdd operators to types you do not own, such as Int or StringVisible only where imported, which is a feature: users opt in
By-name and context-function parametersBlocks that look like language keywordsEvaluation happens when you decide, possibly never or many times
apply and StringContext extensionsmetric("x"), q"..."Strings are checked at run time unless you add a macro

Precedence is where DSLs silently go wrong. From lowest to highest, an operator's precedence is fixed by its first character: all letters, then |, ^, &, then = !, then < >, then :, then + -, then * / %, then every other special character. Operators ending in : are right-associative and are called on their right operand. Assignment-like operators ending in = (but not <=, >=, != or starting with =) bind lowest of all.

The consequence for a rules DSL is concrete. If you name your combinators and and or, they share one precedence and associate left, so a or b and c means (a or b) and c, the opposite of what every reader assumes. Naming them && and || gives & higher precedence than |, matching boolean intuition, and > binds tighter than both, so comparisons need no parentheses.

Advertisement

Builders with context functions

Configuration-style DSLs want nested blocks where certain words are only legal inside a block. Scala 3 context functions express exactly that. A parameter of type RuleBuilder ?=> Unit is a block that receives a RuleBuilder as a given, so helper functions that take (using RuleBuilder) compile inside the block and fail to compile outside it.

// Scala 3
final class RuleBuilder(val name: String):
  var condition: Option[Cond] = None
  var severity: Severity = Severity.Warn
  val labels = scala.collection.mutable.LinkedHashMap.empty[String, String]

def alert(name: String)(body: RuleBuilder ?=> Unit): Rule =
  given b: RuleBuilder = RuleBuilder(name)
  body                                   // runs with b as the given builder
  val cond = b.condition.getOrElse(
    throw IllegalArgumentException(s"alert '$name' has no when(...) clause"))
  Rule(name, cond, b.severity, b.labels.toMap)

def when(c: Cond)(using b: RuleBuilder): Unit       = b.condition = Some(c)
def severity(s: Severity)(using b: RuleBuilder): Unit = b.severity = s
def label(k: String, v: String)(using b: RuleBuilder): Unit = b.labels(k) = v

Two properties make this pattern good. Scope safety: calling when at top level is a compile error because no RuleBuilder is in scope. Encapsulation: the mutable builder never escapes; alert returns an immutable Rule. Its weakness is visible in the code: a missing when is only caught at run time. Keep the helpers in an object the user imports, so short names such as label do not leak into every file.

Why not a plain by-name parameter, body: => Unit? Because then the helpers need some other way to find the current builder, usually a thread-local or a global mutable variable, and both break as soon as two rules are built concurrently or one block is nested in another. The context function passes the builder explicitly through the type system, so nesting and concurrency are simply ordinary scoping.

Phantom types: required fields checked at compile time

When a mistake is common and expensive, move it to the compiler. A phantom type parameter records what has been set; it never holds a value. The final build demands evidence that every required parameter is Present.

import scala.annotation.implicitNotFound

sealed trait Missing
sealed trait Present

@implicitNotFound("This alert has no condition yet: call .when(...) before .build")
sealed trait HasCond[C]
object HasCond:
  given HasCond[Present] = new HasCond[Present] {}

final class RuleSpec[C] private (name: String, cond: Option[Cond], sev: Severity):
  def when(c: Cond): RuleSpec[Present] = new RuleSpec[Present](name, Some(c), sev)
  def severity(s: Severity): RuleSpec[C] = new RuleSpec[C](name, cond, s)
  def build(using HasCond[C]): Rule = Rule(name, cond.get, sev, Map.empty)

object RuleSpec:
  def apply(name: String): RuleSpec[Missing] = new RuleSpec[Missing](name, None, Severity.Warn)

RuleSpec("disk-full").severity(Severity.Page).build
// error: This alert has no condition yet: call .when(...) before .build

The @implicitNotFound message is the difference between a DSL people adopt and one they fight. Without it the user sees a generic missing-instance error naming types they never wrote. Every evidence type in a DSL deserves a sentence that tells the user what to do. Phantom types cost compile-time complexity, so use them for the two or three invariants that matter, not for everything.

Worked example: an alerting DSL with two interpreters

Put the pieces together. The condition language is a deep embedding: a sealed enum, so the compiler can check that every interpreter handles every case (sealed ADTs explains why). Operators only build nodes.

// Scala 3
enum Severity:
  case Warn, Page

enum Cond:
  case Above(metric: String, limit: Double)
  case Below(metric: String, limit: Double)
  case AllOf(l: Cond, r: Cond)
  case AnyOf(l: Cond, r: Cond)

  def &&(that: Cond): Cond = Cond.AllOf(this, that)
  def ||(that: Cond): Cond = Cond.AnyOf(this, that)

final case class Rule(name: String, cond: Cond, severity: Severity, labels: Map[String, String])

final case class MetricRef(name: String):
  def >(limit: Double): Cond = Cond.Above(name, limit)
  def <(limit: Double): Cond = Cond.Below(name, limit)

def metric(name: String): MetricRef = MetricRef(name)

// Interpreter 1: evaluate against one sample. A missing metric is false, never an exception.
def eval(c: Cond, sample: Map[String, Double]): Boolean = c match
  case Cond.Above(m, x) => sample.get(m).exists(_ > x)
  case Cond.Below(m, x) => sample.get(m).exists(_ < x)
  case Cond.AllOf(l, r)   => eval(l, sample) && eval(r, sample)
  case Cond.AnyOf(l, r)   => eval(l, sample) || eval(r, sample)

// Interpreter 2: render a fully parenthesised text form for review and diffs.
def render(c: Cond): String = c match
  case Cond.Above(m, x) => s"$m > $x"
  case Cond.Below(m, x) => s"$m < $x"
  case Cond.AllOf(l, r)   => s"(${render(l)} AND ${render(r)})"
  case Cond.AnyOf(l, r)   => s"(${render(l)} OR ${render(r)})"

val checkoutSlow = alert("checkout-slow") {
  when(metric("p99_ms") > 250 && metric("error_rate") > 0.02 || metric("up") < 1)
  severity(Severity.Page)
  label("team", "payments")
}

Trace the precedence on the when line. > and < bind tightest, producing three leaf nodes. && binds tighter than ||, so the tree is AnyOf(AllOf(Above(p99_ms,250), Above(error_rate,0.02)), Below(up,1)), and render prints ((p99_ms > 250.0 AND error_rate > 0.02) OR up < 1.0). Rendering every rule fully parenthesised in code review is the cheapest way to catch a precedence surprise.

Evaluating against Map("p99_ms" -> 310.0, "error_rate" -> 0.01, "up" -> 1.0) gives false: latency is high but the error rate is not, and the service is up. Because the condition is data, a third interpreter is cheap: a linter that walks the tree and flags metric names missing from your metrics catalogue, a check that a shallow embedding could never run before deployment.

String interpolators and compile-time checks

Sometimes the domain already has a textual syntax, and users would rather write it. An extension on StringContext gives you a custom prefix such as rule"p99_ms > 250". The plain version parses at run time, so a typo becomes a startup failure. To reject it at compile time you need an inline method with a macro that inspects the literal parts, which is how libraries offer compile-checked SQL or JSON literals. The mechanics are covered in Scala 3 macros; the design advice is to start with the runtime version, keep the parser a pure function, and add the macro only when bad literals actually reach production.

Failure modes

  • Precedence surprises: alphanumeric and and or share one precedence. Use && and || or require parentheses, and render rules fully parenthesised in review.
  • Operator soup: symbols such as ~>, |+| and <*> are unsearchable for newcomers. Give every symbolic operator an alphanumeric alias and document both.
  • Implicit conversions that make literals into DSL nodes. They hide what code does; Scala 3 warns on their use unless the feature is imported. Prefer an explicit constructor such as metric("x").
  • Unreadable compile errors from type-level encodings. Put @implicitNotFound on every evidence type and add a test that asserts the message.
  • Compile-time blowup from deep implicit search or large type-level computations. Measure compile time in CI.
  • Mutable builder leakage: a builder captured by a lambda that outlives the block. Return immutable values and never expose the builder type in a public result.
  • Interpreters drifting out of sync. Keep the AST sealed and avoid wildcard cases in interpreter matches so a new node breaks the build.

Trade-offs

Every DSL is a second language your team must learn, so its syntax must pay for itself. Shallow embeddings are cheap and debuggable with an ordinary debugger; deep embeddings cost an ADT and interpreters but turn programs into data you can lint, store and translate. Symbols read well to experts and badly to everyone else. Compile-time guarantees remove whole classes of runtime errors at the price of harder errors and slower builds. A reasonable default is a deep embedding with conventional operators, a context-function builder, phantom types for at most a few invariants, and no implicit conversions. Implicit resolution itself is explained in Scala 3 givens.

What to do next

  1. Write five real programs in the DSL you want before writing any library code; design from call sites.
  2. Decide shallow or deep by asking whether you will ever inspect, store or translate a program.
  3. Choose operator names by precedence, then render a fully parenthesised form and test it.
  4. Use a context-function builder for nested configuration, and keep its helpers behind an import.
  5. Move the one or two most expensive mistakes into phantom types, each with an @implicitNotFound message.
  6. Seal the AST, keep interpreter matches exhaustive, and add a lint interpreter early.
  7. Add CI tests that compile examples and that assert bad examples fail with the intended message.
Key takeaway: Design a Scala DSL from its call sites. Choose a deep embedding when programs must be inspected, stored or translated, and a shallow one when they only run. Pick operator names by their precedence, because the first character decides how expressions parse, and render rules fully parenthesised to catch surprises. Use context-function builders for scoped configuration, phantom types with readable implicitNotFound messages for the few invariants that matter, sealed ASTs so interpreters stay in step, and avoid implicit conversions.