Domain Specific Languages
|
This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production. The reference material consulted while preparing these pages is listed in the section bibliography. |
This page is written against Scala 3.10 (3.10.0, released 2026-10-12) and every example marked as run was compiled and run with Scala 3.10.0 through Scala CLI on JDK 26 (checked on 2026-10-16). The language features used here have been stable since Scala 3.0, so the 3.9 LTS behaves the same; this was not run on 3.9 and is taken from the release notes.
A domain specific language (DSL) is a small language shaped for one problem. An internal DSL lives inside a host
language and reuses its parser and type checker; an external DSL has its own syntax and needs its own parser. Scala is
a popular host for internal DSLs because method calls can be written without dots and parentheses, blocks can be passed
as arguments and the compiler can supply implicit context. The examples use the trailhead scenario, a toolkit that plans
hikes on a network of mountain trails, and build towards a route DSL that reads
from("Refugio") to "Pico" via Ridge. The building blocks are described in depth elsewhere:
Extension Methods,
Context Functions,
Givens & Using Clauses and
Structural Types, Selectable & Dynamic.
This page shows how they combine.
Infix methods
A method with one parameter can be called as a op b instead of a.op(b). Symbolic names such as + or ~> may always
be used that way. An alphanumeric method name such as to or via should be declared infix to be written without a
dot; since Scala 3.4 the compiler warns otherwise (on 3.0 to 3.3, including the 3.3 LTS, only under -source future). The first example chains two infix methods to build a route:
//> using scala 3.10.0
package trailhead
enum Difficulty:
case Easy, Moderate, Hard
case class Segment(from: String, to: String, km: Double, difficulty: Difficulty)
case class Route(segments: Vector[Segment]):
def km: Double = segments.map(_.km).sum
override def toString = segments.map(s => s"${s.from}->${s.to}").mkString("Route(", ", ", ")")
object Ridge extends Segment("Refugio", "Pico", 8.5, Difficulty.Hard)
case class Leg(from: String):
infix def to(dest: String): Hop = Hop(from, dest)
case class Hop(from: String, to: String):
infix def via(s: Segment): Route = Route(Vector(s.copy(from = from, to = to)))
def from(place: String): Leg = Leg(place)
@main def run(): Unit =
val r = from("Refugio") to "Pico" via Ridge
println(r)
println(r.km)
val r2 = from("Refugio").to("Pico").via(Ridge)
println(r == r2)
Route(Refugio->Pico)
8.5
true
Infix calls associate to the left, so from("Refugio") to "Pico" via Ridge is from("Refugio").to("Pico").via(Ridge),
which the last line confirms. Each step returns a different type (Leg, Hop, Route), and that is what makes the
language safe: via exists only on a Hop, so the words can only be written in the intended order. The rules for
infix operators are in the Scala 3 Reference: Rules for Operators.
Leaving infix off a method that is called infix compiles but warns, and the warning names the two ways out:
//> using scala 3.10.0
package trailhead
case class Leg(from: String):
def to(dest: String): String = s"$from..$dest"
@main def run(): Unit =
val l = Leg("A")
println(l to "B")
Alphanumeric method to is not declared infix; it should not be used as infix operator.
Instead, use method syntax .to(...) or backticked identifier `to`.
The latter can be rewritten automatically under -rewrite -source 3.4-migration.
1 warning found
A..B
Methods from Java libraries and the standard library are exempt, which is why 1 to 10 and xs contains x need no
declaration. A postfix operator (5 km, with no following operand) is rejected by the compiler as written below; the
scala.language.postfixOps import exists for it but is discouraged, so a DSL should be designed so that every word is
followed by an argument, or use the dot, as in 5.km:
9 | println(5 km)
| ^^
| ',' or ')' expected, but identifier found
Extension methods as vocabulary
Infix methods have to be members of a class. Extension methods let a DSL add words to types it does not own, such as
String or Double, and they combine with infix. The next example defines a vocabulary for distances with an opaque
type (see Opaque Types & Value Classes), so that
8.5.km is a Km and not a bare Double, and an infix operator on it:
//> using scala 3.10.0
package trailhead
object Units:
opaque type Km = Double
object Km:
def apply(d: Double): Km = d
extension (k: Km)
def +(o: Km): Km = k + o
infix def max(o: Km): Km = if k >= o then k else o
def show: String = s"$k km"
extension (d: Double) def km: Km = Km(d)
extension (i: Int) def km: Km = Km(i.toDouble)
extension (sc: StringContext)
def route(args: Any*): List[String] =
sc.s(args*).split("->").map(_.trim).toList
@main def run(): Unit =
import Units.*
val total = 8.5.km + 3.km + 1.2.km
println(total.show)
println((2.km max 5.km).show)
val dest = "Pico"
println(route"Refugio -> Arch -> $dest")
12.7 km
5.0 km
List(Refugio, Arch, Pico)
Source: Scala 3 Book: Extension Methods
The opaque type is defined inside an object. A top-level opaque type is transparent everywhere in its own file, so +
would resolve to the Double method and the show extension would not be found; the object gives the abstraction a
real boundary. The last extension, on StringContext, defines a custom interpolator: route"..." is rewritten to
StringContext("...", ...).route(...), and the extension can do anything with the parts. This is the DSL form used for
embedded SQL, JSON or regular expressions; see Strings & Interpolation
for the interpolators in the library.
Extension methods are the only way to add words to an existing type that is checked at the call site; the old route, implicit classes, still works but is described in Extension Methods.
Operator precedence
Symbolic operators have a fixed precedence determined by their first character. This is the main constraint when choosing operator names, because users cannot change it. From lowest to highest binding:
-
any letter (alphanumeric operators such as
viaormax) -
| -
^ -
& -
=and! -
<and> -
: -
+and- -
*,/and% -
all other special characters
- An operator ending in
:is right associative, which is how `1 -
2 :: Nil` works. The example defines three operators and shows the effect:
- An operator ending in
//> using scala 3.10.0
package trailhead
object Ops:
extension (a: Int)
def |>(f: Int => Int): Int = f(a)
def ~>(b: Int): Range = a to b
def **(b: Int): Int = math.pow(a, b).toInt
@main def run(): Unit =
import Ops.*
println(2 + 3 ** 2) // ** starts with *, so it binds tighter than +
println(1 + 2 |> (_ * 10)) // | binds looser than +
println(1 ~> 4)
val xs = 1 :: 2 :: Nil
println(xs)
11
30
Range 1 to 4
List(1, 2)
2 + 3 ** 2 is 2 + (3 ** 2), and 1 + 2 |> f is (1 + 2) |> f. A DSL that follows ordinary arithmetic
expectations should pick operator names that start with the character whose precedence it wants. Alphanumeric infix
methods bind loosest of all, so from("Refugio") to "Pico" via Ridge needs no parentheses, but mixing them with
symbolic operators on either side may need some.
By-name parameters for control structures
A parameter declared => T is evaluated each time it is used, and not at the call. Together with the Scala 3 rule that
a final argument can be written as an indented block after a colon, by-name parameters let a library define its own
control structures that look like keywords. The examples define timing, retry and a negated if:
//> using scala 3.10.0
package trailhead
def timed[T](label: String)(body: => T): T =
val t0 = System.nanoTime()
val result = body
val ms = (System.nanoTime() - t0) / 1_000_000
println(s"$label finished (took >= 0 ms: ${ms >= 0})")
result
def retry[T](times: Int)(body: => T): Option[T] =
var left = times
while left > 0 do
left -= 1
try return Some(body)
catch case e: RuntimeException => println(s"attempt failed: ${e.getMessage}")
None
def unless(cond: Boolean)(body: => Unit): Unit = if !cond then body
@main def run(): Unit =
val n = timed("sum")((1 to 1000).sum)
println(n)
var calls = 0
val r = retry(3):
calls += 1
if calls < 3 then throw RuntimeException(s"call $calls") else "ok after " + calls
println(r)
unless(r.isEmpty):
println("have a result")
sum finished (took >= 0 ms: true)
500500
attempt failed: call 1
attempt failed: call 2
Some(ok after 3)
have a result
retry(3): evaluates its block up to three times because the parameter is by-name; with a plain Int parameter the
block would run once, before the call. Library uses of the same idea are Option.getOrElse, Try(...), assert and
scala.util.boundary. A by-name parameter is also re-evaluated on every use inside the method, so a body used twice
runs twice:
//> using scala 3.10.0
package trailhead
def twice(body: => Unit): Unit = { body; body }
@main def run(): Unit =
twice:
println("hello")
var i = 0
twice(i += 1)
println(i)
hello
hello
2
See A Tour of Scala: By-name Parameters and
Methods & Parameters. Java’s closest equivalent is a
Supplier or Runnable argument that the caller must wrap in a lambda (see
Lambdas & Method References).
Context function builders
The most powerful tool for nested DSLs is the context function: a block of code that receives an implicit "current
object". It is described in full in Context Functions; this
example applies it to a hike plan. The plan and day functions open a scope and put a given in it; the words to,
km and rest find the nearest enclosing Day through a using clause, with no object passed around:
//> using scala 3.10.0
package trailhead
import scala.collection.mutable.ArrayBuffer
case class Hop(from: String, to: String, km: Double)
class Day(val n: Int):
val hops = ArrayBuffer[Hop]()
var restMin = 0
class Plan(val name: String):
val days = ArrayBuffer[Day]()
def plan(name: String)(body: Plan ?=> Unit): Plan =
given p: Plan = Plan(name)
body
p
def day(body: Day ?=> Unit)(using p: Plan): Unit =
given d: Day = Day(p.days.size + 1)
body
p.days += d
extension (from: String)
infix def to(dest: String)(using d: Day): Hop = Hop(from, dest, 0)
extension (h: Hop)
def km(k: Double)(using d: Day): Unit = d.hops += h.copy(km = k)
def rest(minutes: Int)(using d: Day): Unit = d.restMin += minutes
@main def run(): Unit =
val weekend = plan("Weekend"):
day:
("Refugio" to "Pico").km(8.5)
rest(30)
("Pico" to "Arch").km(3.2)
day:
("Arch" to "Refugio").km(5.0)
for d <- weekend.days do
println(s"day ${d.n}: ${d.hops.map(h => s"${h.from}->${h.to} (${h.km} km)").mkString(", ")}; rest ${d.restMin} min")
println(weekend.days.flatMap(_.hops).map(_.km).sum)
day 1: Refugio->Pico (8.5 km), Pico->Arch (3.2 km); rest 30 min
day 2: Arch->Refugio (5.0 km); rest 0 min
16.7
Using a word outside its scope is a compile-time error, which is the main advantage over a builder that keeps the current object in mutable global state:
//> using scala 3.10.0
package trailhead
class Day
def day(body: Day ?=> Unit): Unit = body(using Day())
def rest(minutes: Int)(using d: Day): Unit = println(s"rest $minutes")
@main def run(): Unit =
rest(10)
day:
rest(5)
9 | rest(10)
| ^
|No given instance of type trailhead.Day was found for parameter d of method rest in package trailhead
The ideas follow the reference’s
Example: Builder Pattern.
Kotlin reaches the same result with lambdas with receivers and @DslMarker; Scala has no equivalent of the marker, so
each nesting level should have its own type and the DSL should not expose words that are valid but wrong at that level.
See Type-safe Builders & DSLs.
Dynamic for open-ended vocabularies
When the vocabulary is not known at compile time (configuration keys, JSON fields, database columns) a class can extend
scala.Dynamic, and the compiler rewrites unknown member accesses to selectDynamic, updateDynamic, applyDynamic
and applyDynamicNamed calls. The mechanism and the typed alternative, Selectable, are in
Structural Types, Selectable & Dynamic.
A DSL use:
//> using scala 3.10.0
package trailhead
import scala.language.dynamics
import scala.collection.mutable
class Config extends Dynamic:
private val values = mutable.LinkedHashMap[String, String]()
def selectDynamic(name: String): String = values.getOrElse(name, s"<unset $name>")
def updateDynamic(name: String)(v: String): Unit = values(name) = v
def applyDynamicNamed(name: String)(args: (String, String)*): Config =
println(s"$name(${args.map((k, v) => s"$k=$v").mkString(", ")})")
args.foreach((k, v) => values(s"$name.$k") = v)
this
override def toString = values.mkString("Config(", ", ", ")")
@main def run(): Unit =
val c = Config()
c.trail = "Ridge"
c.start = "Refugio"
println(c.trail)
println(c.missing)
c.limits(maxKm = "12", maxAscent = "900")
println(c)
println(c.selectDynamic("limits.maxKm"))
Ridge
<unset missing>
limits(maxKm=12, maxAscent=900)
Config(trail -> Ridge, start -> Refugio, limits.maxKm -> 12, limits.maxAscent -> 900)
12
Source: Scala API: scala.Dynamic
The price is that every name compiles, so a typo is found at run time (c.misssing returns the placeholder). Real
members always win over the rewrite, and a typo on a type that is not Dynamic is still a compile-time error:
12 | println("abc".lenght)
| ^^^^^^^^^^^^
|value lenght is not a member of String - did you mean ("abc" : String).length?
Use Dynamic only where the names truly are data, and prefer the typed forms above everywhere else.
Implicit conversions: use sparingly
An old DSL technique lets a bare String stand in for a Place through an implicit conversion. In Scala 3 the
conversion is a given Conversion[String, Place], and using it is a language-feature warning unless the importing
code has import scala.language.implicitConversions:
//> using scala 3.10.0
package trailhead
case class Place(name: String)
given Conversion[String, Place] = Place(_)
def goto(p: Place): String = s"going to ${p.name}"
@main def run(): Unit =
println(goto("Pico"))
there was 1 feature warning; re-run with -feature for details
1 warning found
going to Pico
The conversions hide type errors and are the reason the feature is gated, so prefer an extension method ("Pico".place)
or an explicit constructor in a DSL. Details are in Conversions, into & Implicit Resolution.
External DSLs
An external DSL is parsed from text (a file, a string or a command line) into the host language’s data structures.
It is the right choice when the users are not programmers, when scripts must be stored and edited outside the code, or
when the syntax cannot be bent into method calls. The result of parsing is normally an enum describing the commands,
which the program then interprets with pattern matching (see
Pattern Matching). This example parses a two-command route
language with regular expression extractors and reports errors with Either:
//> using scala 3.10.0
package trailhead
enum Command:
case Walk(from: String, to: String, km: Double)
case Rest(minutes: Int)
object Parser:
private val WalkR = """walk\s+(\w+)\s*->\s*(\w+)\s+(\d+(?:\.\d+)?)km""".r
private val RestR = """rest\s+(\d+)min""".r
def parseLine(line: String): Either[String, Command] = line.trim match
case WalkR(a, b, km) => Right(Command.Walk(a, b, km.toDouble))
case RestR(m) => Right(Command.Rest(m.toInt))
case other => Left(s"cannot parse: '$other'")
def parse(src: String): Either[String, List[Command]] =
src.linesIterator.filter(_.trim.nonEmpty).toList.map(parseLine).partitionMap(identity) match
case (Nil, cmds) => Right(cmds)
case (errs, _) => Left(errs.mkString("; "))
@main def run(): Unit =
val ok = """
|walk Refugio -> Pico 8.5km
|rest 30min
|walk Pico -> Arch 3.2km
|""".stripMargin
println(Parser.parse(ok))
println(Parser.parse("walk Refugio -> Pico\nfly home"))
Right(List(Walk(Refugio,Pico,8.5), Rest(30), Walk(Pico,Arch,3.2)))
Left(cannot parse: 'walk Refugio -> Pico'; cannot parse: 'fly home')
For a grammar with nesting or operators a hand-written line parser does not scale. The usual library choice is the
scala-parser-combinators module, which was split out of the standard library in Scala 2.11 and is published to Maven
Central as org.scala-lang.modules::scala-parser-combinators; it lets the grammar be written as Scala code. It is
mentioned here only as a pointer and was not run for this page, so check the current version and its Scala 3 support on
Scaladex. Other options are the FastParse and Parsley libraries from the wider ecosystem.
Choosing between the techniques
| Technique | Good for | Watch out for |
|---|---|---|
Infix methods and |
Sentence-like call chains, with each word returning a type that offers the next |
Left associativity, the |
Extension methods |
Adding words to types you do not own; units with opaque types |
Members win over extensions; imports needed for the vocabulary |
Operator names |
Mathematical notations |
Fixed precedence from the first character; unreadable symbols |
By-name parameters |
New control structures ( |
Re-evaluated on each use; |
Context functions |
Nested builders, scoped capabilities |
The innermost given of a type wins; no |
|
Open vocabularies such as configuration or JSON paths |
Typos found at run time; one more |
Custom interpolators |
Embedded text languages (SQL, paths, routes) |
Parts are checked at run time unless a macro is used |
Where the books are outdated
-
Scala 2 DSL tutorials define words with
implicit classand rely on postfix operators (5 km). In Scala 3 the postfix form is rejected without a language import and is discouraged, and implicit classes are replaced by extension methods. -
Alphanumeric operators used infix without
infixwere silent in Scala 2; they now produce a warning. -
Builders that thread an
implicitparameter by hand use the older implicit syntax; the current form is a context function with?=>. -
The parser combinators that used to be in
scala.util.parsing.combinatorare no longer in the standard library.