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.
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.
| Question | Shallow | Deep |
|---|---|---|
| How many meanings? | One | As many interpreters as you write |
| Can you inspect or optimise a program before running it? | No | Yes, it is data |
| Code to write | Least | ADT plus one function per interpreter |
| Adding a construct | Add a function | Add a case and update every interpreter (the compiler lists them) |
| Typical use | Test assertions, small config helpers | Rules, 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.
| Tool | What it buys | The rule to respect |
|---|---|---|
| Symbolic methods | a > 250, x && y | Precedence comes from the first character of the name, not from your intent |
| Infix alphanumeric methods | a and b | In Scala 3, mark them infix; alphanumeric names share the lowest precedence |
| Extension methods | Add operators to types you do not own, such as Int or String | Visible only where imported, which is a feature: users opt in |
| By-name and context-function parameters | Blocks that look like language keywords | Evaluation happens when you decide, possibly never or many times |
apply and StringContext extensions | metric("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.
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) = vTwo 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 .buildThe @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
andandorshare 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
@implicitNotFoundon 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
- Write five real programs in the DSL you want before writing any library code; design from call sites.
- Decide shallow or deep by asking whether you will ever inspect, store or translate a program.
- Choose operator names by precedence, then render a fully parenthesised form and test it.
- Use a context-function builder for nested configuration, and keep its helpers behind an import.
- Move the one or two most expensive mistakes into phantom types, each with an
@implicitNotFoundmessage. - Seal the AST, keep interpreter matches exhaustive, and add a lint interpreter early.
- Add CI tests that compile examples and that assert bad examples fail with the intended message.