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)

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 via or max)

  • |

  • ^

  • &

  • = 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:

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

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 infix

Sentence-like call chains, with each word returning a type that offers the next

Left associativity, the infix warning, no postfix form

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 (retry, timed, unless)

Re-evaluated on each use; return inside the block leaves the enclosing method

Context functions

Nested builders, scoped capabilities

The innermost given of a type wins; no @DslMarker equivalent

Dynamic

Open vocabularies such as configuration or JSON paths

Typos found at run time; one more import scala.language.dynamics

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 class and 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 infix were silent in Scala 2; they now produce a warning.

  • Builders that thread an implicit parameter 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.combinator are no longer in the standard library.