Scala 3 Conventions and Tricks Used in This Book
Every example in this book is written in Scala 3 and runs with scala-cli. The code leans on a small set of language features that repeat in nearly every chapter. If you know another language but are new to Scala 3, read this chapter first. Each section shows a snippet taken from the book’s own source code.
Running an Example with scala-cli
Each project is a directory of .scala files with no build.sbt. Build settings live at the top of a file as //> using directives:
1 //> using scala 3.6.4
2 //> using dep com.lihaoyi::requests:0.9.3
3 //> using dep com.lihaoyi::ujson:4.4.3
The first line pins the compiler version. The dep lines declare library dependencies using Maven coordinates. The :: between group and artifact means “use the version built for the Scala version in use”. A project-wide project.scala file can hold directives shared by every file.
Run a project from its directory:
1 scala-cli run .
Indentation Instead of Braces
Scala 3 lets you drop braces. Blocks are defined by indentation, and keywords replace the old punctuation:
1 if b.isOver then score(b, player, depth)
2 else if isMaximizing then
3 boundary:
4 var best = Int.MinValue
5 for (r, c) <- b.emptyCells do
6 best = math.max(best, step(r, c))
7 best
The pieces to recognize:
if cond then ... else ...replacesif (cond) { ... } else { ... }.for ... do ...andwhile ... do ...replace the braced loop body.matchcases sit in an indented block, no braces required.method:passes an indented block as the last argument. Insource-code/search/TicTacToe.scala:52the code reads(0 until 3).map: r => ....
Braces still work and a few ported files use them. Follow the style of the file you edit.
Top-Level Definitions and @main
Scala 3 allows methods and values at the top of a file, outside any object. A program entry point is marked with @main:
1 @main def toolsDemo(args: String*): Unit =
2 BuiltinTools.registerAll()
3 val model = if args.nonEmpty then args(0) else "mistral"
@main generates a JVM main method, so the name of the method becomes the program name. Parameters become command-line arguments, and a default value makes the argument optional. You can define several @main methods in one project and pick one with scala-cli run . --main-class <name>.
Case Classes and copy
A case class gives you a constructor, accessors, equals, hashCode, and a readable toString for free. The examples use them as immutable records:
1 final case class Term(coefficient: Double, variable: Variable, exponent: Int):
2 def negate: Term = copy(coefficient = -coefficient)
3 def scale(k: Double): Term = copy(coefficient = coefficient * k)
copy builds a changed duplicate. Named arguments let you change one field and leave the rest. Constructor parameters can have defaults, so callers may omit them:
1 case class NeuralNetwork(
2 numInputs: Int,
3 numHidden: Int,
4 numOutputs: Int,
5 w1: Array[Array[Float]],
6 w2: Array[Array[Float]],
7 learningRate: Float = 0.5f
8 ):
The neural network chapter trains by returning copy(w1 = newW1, w2 = newW2) instead of mutating the weights in place. Prefer this style: return a new value, keep the old one intact.
Enums as Data Types
A Scala 3 enum can be a simple set of constants or a full algebraic data type whose cases carry data:
1 enum Term:
2 case Var(name: String) // logic variable: ?who
3 case Kw(name: String) // symbolic atom: :mark (name is UPPER-CASE)
4 case Str(value: String) // string literal: "hello"
5 case Num(value: Long) // integer literal: 42
6 case Neural(inner: Term) // neural predicate: ~:codes-in
Enums can also declare fields and methods. This is used to attach a wire value to each case:
1 enum Role(val value: String):
2 case System extends Role("system")
3 case User extends Role("user")
4 case Assistant extends Role("assistant")
Role.values returns all cases, which makes it easy to parse a string back into an enum.
Pattern Matching
match tests a value against patterns and picks the first that fits. It replaces long if/else chains and switch statements, and it can see inside data:
1 board.winner match
2 case Some(p) if p == player => 10 - depth
3 case Some(_) => depth - 10
4 case None => 0
Useful pattern forms used in the book:
- Case-class patterns bind their fields:
case Filled(p) => p.toString. - Alternatives share a body:
case _: Var | _: Neural => false. - Guards add a condition:
case line if line.forall(...) => Player.X. - Cons patterns drive recursion:
case Nil => ...; case pat :: rest => .... - Tuple patterns split pairs:
case (isTarget, isPredicted) => ....
The compiler warns when a match is not exhaustive, so keep patterns complete.
Options and Either
Scala uses Option for a value that may be absent, instead of null:
1 Json.parse(raw)("response").flatMap(_.str).getOrElse("")
Some(x) holds a value, None is empty. Methods like map, flatMap, filter, and getOrElse chain without null checks. Option.when(cond)(value) builds an Option from a condition.
Use Either for an operation that can fail with a reason. By convention Right is success and Left is the error:
1 def getChat(messages: List[ChatMessage], model: String): Either[String, String] =
2 try
3 val response = requests.post(...)
4 if response.statusCode == 200 then Right(content)
5 else Left(s"Ollama API request failed (HTTP ${response.statusCode})")
6 catch
7 case e: Exception => Left(s"Error connecting to Ollama: ${e.getMessage}")
For-Comprehensions
A for-comprehension builds a result from one or more generators. With yield it returns a collection:
1 def emptyCells: List[(Int, Int)] =
2 (for
3 r <- 0 until 3
4 c <- 0 until 3
5 if cells(r * 3 + c) == Cell.Empty
6 yield (r, c)).toList
The same syntax works over Option and stops at the first None:
1 for
2 s <- row("subject").flatMap(_.str)
3 p <- row("predicate").flatMap(_.str)
4 o <- row("object").flatMap(_.str)
5 yield Triple(sanitize(s), sanitize(p), sanitize(o))
Use for ... do ... without yield when you want a loop with side effects, such as printing or updating a mutable variable.
Collections
The standard collections do most of the work. The idioms that appear most often:
1 terms.groupBy(_.exponent).view.mapValues(_.map(_.coefficient).sum).toMap
2 words.sliding(maxWords, step).map(_.mkString(" ")).toList
3 Array.tabulate(numHidden, numOutputs): (h, o) => w2(h)(o) + delta
4 population.sortBy(-_.fitness)
5 lines.collectFirst { case line if line.forall(ok) => Player.X }
Read them as a pipeline: transform a collection into another collection. groupBy buckets by a key, view.mapValues changes values lazily, sliding makes overlapping windows, tabulate builds an array by index, and sortBy(-_.fitness) sorts high to low by negating the key.
String Interpolation
An s"..." string substitutes expressions with $name or ${expression}. An f"..." string adds printf-style format specifiers:
1 println(f" Epoch $epoch%5d avg error: $error%.6f")
Triple-quoted strings span lines and do not need escaped quotes. stripMargin removes the leading | so the text lines up in the source:
1 s"""|SELECT ?p WHERE {
2 | $entity1Uri ?p $entity2Uri .
3 |} LIMIT 10
4 |""".stripMargin
Extension Methods, Givens, and Context Parameters
An extension method adds a method to a type you do not own:
1 extension (sc: StringContext)
2 def kw(args: Any*): Term.Kw =
3 Term.Kw(sc.s(args*).trim.toUpperCase.replace(' ', '-'))
That snippet defines the custom kw"mark" interpolator. A given provides a value the compiler passes to methods automatically, and a using parameter receives it:
1 given default: NeuralConfig = NeuralConfig()
2
3 def prove(patterns: List[Triple], env: Env)(using Graph, NeuralConfig): List[Env] =
4 ...
Call summon[NeuralConfig] to fetch the current given, or define a local given to override it inside a block. These are Scala 3’s replacement for Scala 2 implicits.
Operator Overloading
Any method whose name is a symbol can be used as an operator. The autograd engine in the transformer chapter defines arithmetic on a Value class so the math reads naturally:
1 def +(other: Value): Value = ...
2 def *(other: Value): Value = ...
3 def unary_- : Value = this * Value(-1.0)
4 def -(other: Value): Value = this + (-other)
unary_- enables the prefix minus, and unary_~ (used in the neural-symbolic chapter) enables a prefix tilde. Operators are ordinary methods, so a + b is the same as a.+(b).
Early Exit with boundary and break
Scala discourages return. To leave a loop early and produce a value, wrap the code in boundary and call break:
1 import scala.util.boundary, boundary.break
2 boundary:
3 for (r, c) <- board.emptyCells do
4 val s = minimax(board.set(r, c, player), false, depth + 1, alpha, beta)
5 best = math.max(best, s)
6 if beta <= best then break(best)
7 best
break(value) exits the nearest enclosing boundary and returns value. This is how the alpha-beta pruning in the search chapter stops exploring a branch.
inline and @tailrec
Two annotations appear in performance-sensitive code. inline asks the compiler to paste the method body at each call site, which removes a call for small helpers:
1 inline def pieceType(p: Int): Int = p & TYPE_MASK
2 inline def opponent(c: Int): Int = if (c == WHITE) BLACK else WHITE
@annotation.tailrec tells the compiler to check that a recursive method is tail-recursive, so it compiles to a loop and cannot overflow the stack:
1 @annotation.tailrec
2 def search(queue: Queue[(Int, List[Int])], visited: Set[Int]): Option[List[Int]] =
3 ...
If the method is not tail-recursive, the compiler reports an error. Use it whenever a loop is written as recursion.
A Note on DSLs
The neural-symbolic chapter goes further and builds a small query language. It uses scala.Dynamic so that v.who becomes a logic variable, a custom nsk"..." interpolator, and a given Conversion so a plain string stands for a keyword. The Laminar web chapter uses a similar style, with := for attributes and <-- for signals. You do not need to know how these are built to use them. Read the DSL section in those chapters when you get there.
Where to Go Next
With these conventions in mind, the rest of the book should read cleanly. Each chapter lists the source directory it uses, and each project runs on its own with scala-cli run ..