Overview
Idiomatic, production-grade Scala 3 on the JVM: sbt builds, the Scala 3 syntax, ADTs and pattern matching, givens and extension methods, typed errors, the cats-effect and ZIO effect systems, and MUnit/ScalaTest testing. Assumes the reader writes Scala already. The content is the part that bites: givens that an import silently misses, IO.pure running a side effect once and early, blocking calls on the compute pool, non-exhaustive matches that only warn, and an sbt testOnly that exits 0 after running nothing.
Tooling
sbt is the default build. Keep one sbt shell open (or sbt --client) for iterative work: every batch invocation boots a JVM and loads the build before running anything.
| Task | Command |
|---|---|
| Full gate | sbt scalafmtCheckAll scalafmtSbtCheck "scalafixAll --check" test |
| Compile main and tests | sbt Test/compile |
| All tests | sbt test |
| One suite | sbt 'testOnly com.acme.FooSuite' |
| One ScalaTest test by substring | sbt 'testOnly com.acme.FooSpec -- -z "rejects symlinks"' |
| Failed and changed tests only | sbt testQuick (~testQuick reruns on save) |
| Format | sbt scalafmtAll scalafmtSbt |
| Dependency tree | sbt dependencyTree (after addDependencyTreePlugin in project/plugins.sbt) |
// build.sbt
ThisBuild / scalaVersion := "3.3.4" // the 3.3.x line is the LTS
ThisBuild / organization := "com.acme"
ThisBuild / semanticdbEnabled := true // scalafix semantic rules need it
lazy val core = project
.settings(
libraryDependencies ++= Seq(
"org.typelevel" %% "cats-effect" % "3.5.4",
"org.scalameta" %% "munit" % "1.0.0" % Test,
"org.typelevel" %% "munit-cats-effect" % "2.0.0" % Test,
),
Test / fork := true, // tests in a separate JVM: clean system properties, no classloader leaks
)
lazy val root = (project in file("."))
.aggregate(core) // commands run at the root reach core
project/build.propertiespins the sbt version;project/plugins.sbtlists plugins.%%appends the Scala binary version to the artifact (cats-effect_3);%is for Java libraries. Scala 3 can use Scala 2.13 artifacts through.cross(CrossVersion.for3Use2_13), except those that ship Scala 2 macros.- Compiler flags: sbt-tpolecat sets strict flags per Scala version (
-deprecation,-feature,-Wunused:all, and-Werrorin CI mode). With warnings as errors, a non-exhaustive match fails the build. - scalafmt reads
.scalafmt.conf, which must setversionandrunner.dialect = scala3. scalafix semantic rules (OrganizeImports,RemoveUnused) need SemanticDB enabled as above. - Scala CLI (the
scalacommand since Scala 3.5) runs scripts and single-module projects configured with//> usingdirectives. Mill is another build tool. Neither usesbuild.sbt.
Scala 3 Syntax
enum Shape:
case Circle(radius: Double)
case Rect(width: Double, height: Double)
def area(shape: Shape): Double = shape match
case Shape.Circle(r) => math.Pi * r * r
case Shape.Rect(w, h) if w == h => w * w
case Shape.Rect(w, h) => w * h
object ids:
opaque type UserId = Long // a Long at runtime, a distinct type at compile time
object UserId:
def apply(value: Long): UserId = value
extension (id: UserId) def value: Long = id
@main def lineCount(path: String): Unit =
val lines = scala.util.Using.resource(scala.io.Source.fromFile(path))(_.getLines().size)
println(s"$path: $lines lines")
- Significant indentation replaces braces;
then/dofollowif/while/forconditions, andendmarkers close long blocks. Pick one style per codebase and have scalafmt enforce it. - Top-level definitions replace package objects.
exportforwards selected members of a field, which gives composition without hand-written delegation. - Union (
String | Int) and intersection (A & B) types;derivesrequests type class derivation (derives CanEqual, circe'sderives Codec.AsObject). opaque typegives a distinct type with no runtime cost.AnyValvalue classes box inside collections and generic code; opaque types never box beyond their underlying type.- Migrating from 2.13: compile with
-source:3.0-migration -rewriteto fix syntax.implicitdefinitions still compile in Scala 3 and interoperate with givens.
ADTs and Pattern Matching
sealed trait Command
object Command:
final case class Upload(source: String, target: String) extends Command
final case class Delete(target: String) extends Command
case object Flush extends Command
def run(cmd: Command): Either[String, Unit] = cmd match
case Command.Upload(src, dst) if dst.isBlank => Left(s"empty target for $src")
case Command.Upload(src, dst) => upload(src, dst)
case Command.Delete(target) => delete(target)
case Command.Flush => Right(())
sealedtraits andenums let the compiler check exhaustiveness. The check only warns, so build with-Werror. The compiler cannot see through guards: keep an unguarded case for every variant.final case classkeeps data types closed. Case classes give structuralequals/hashCode,copy, and an extractor for patterns.- Type patterns are erased:
case xs: List[String]matches anyListand only emits an unchecked warning. Match on the elements, or keep the type in the ADT. - A value no case matches throws
MatchErrorat runtime.collect { case ... }and otherPartialFunctionuses make partiality explicit. - Custom extractors:
object Email { def unapply(s: String): Option[(String, String)] = ... }makescase Email(user, domain) =>work on plain strings. - Scala 3 deprecates non-local
returnfrom inside a lambda. Usescala.util.boundary(3.3):
import scala.util.boundary, boundary.break
def firstNegative(xs: List[Int]): Option[Int] =
boundary:
for x <- xs do
if x < 0 then break(Some(x))
None
Givens, Using and Extension Methods
trait Show[A]:
extension (a: A) def show: String
object Show: // the companion is in the implicit scope of Show[X]
given Show[Int] with
extension (a: Int) def show: String = a.toString
given [A](using s: Show[A]): Show[List[A]] with
extension (as: List[A]) def show: String = as.map(a => s.show(a)).mkString("[", ", ", "]")
def describe[A: Show](a: A): String = s"value: ${a.show}" // the context bound puts Show[A] in scope
describe(42) // Show[Int] found in the companion, no import
describe(List(1, 2, 3)) // Show[List[Int]] built from Show[Int]
- Put instances in the companion of the type class or of the data type: that is the implicit scope, searched without imports. Instances in unrelated objects (orphans) need explicit imports and invite ambiguity.
import a.*does not importgivendefinitions. Useimport a.givenorimport a.{*, given}. Scala 2implicitdefinitions, including most of cats' syntax, still arrive with*.- Extension syntax on a bare value (
42.showoutside anyShowcontext) searches the implicit scope of the receiver's type, which does not includeShow's companion; bring the instances into lexical scope withimport Show.given. - Two eligible givens of the same type fail with an ambiguity error. Resolve by specificity or by moving the fallback into a lower-priority parent trait of the companion.
usingparameters carry context (anExecutionContext, a transaction, a config). Passing ordinary dependencies implicitly hides them from readers; use constructor parameters.- Implicit conversions (
given Conversion[A, B]) need a language import and surprise readers. Extension methods cover the legitimate cases. - Automatic derivation of deep type class trees (circe's fully automatic mode) is slow to compile. Semi-automatic
deriveson each type keeps compile times flat.
Errors
enum ConfigError:
case Missing(key: String)
case Invalid(key: String, value: String)
def port(env: Map[String, String]): Either[ConfigError, Int] =
for
raw <- env.get("PORT").toRight(ConfigError.Missing("PORT"))
port <- raw.toIntOption.filter(p => p > 0 && p < 65536).toRight(ConfigError.Invalid("PORT", raw))
yield port
def readConfig(path: Path): Either[Throwable, String] =
Try(Files.readString(path)).toEither // wrap the throwing Java call once, at the edge
Either[E, A]with an error ADT for expected failures,Optionfor absence,Tryonly to capture exceptions from Java or legacy APIs, converted toEitherat the edge. For-comprehensions chain all three.- Throw for bugs. Inside an effect type, failures travel in the effect (
IO.raiseError, ZIO's error channel). - Catch with
case NonFatal(e) =>.case e: Throwablealso catchesInterruptedExceptionand VM errors. scala.util.Using.resource(open())(use)andUsing.ManagercloseAutoCloseableresources on every path.
Effects: Cats Effect and ZIO
Pick one effect system per codebase. Future is eager, memoized and not cancellable; it suits simple async glue, and blocking code inside it belongs in blocking {}.
import cats.effect.{IO, IOApp, Resource}
object Main extends IOApp.Simple:
def fetch(id: Long): IO[String] =
IO.blocking(legacyClient.get(id)) // blocking call on the blocking pool
val run: IO[Unit] =
Resource.fromAutoCloseable(IO(openDatabase())).use { db =>
IO.parTraverseN(4)((1L to 20L).toList)(fetch) // at most 4 in flight
.flatMap(pages => IO.blocking(db.saveAll(pages)))
}
- An
IOvalue describes a computation; nothing runs until the runtime executes it.IO.pure(expr)evaluatesexpronce, at construction. Side effects go inIO(...)/IO.delay. - Blocking calls go in
IO.blocking, orIO.interruptibleto allow cancellation by thread interrupt. A blocking call in plainIOstalls one of the compute threads, of which there is one per core. Resourcefor anything with a release step;usereleases on success, error and cancellation.Reffor shared state,Deferredfor a one-shot signal,Queuefor handoff between fibers. A fiber fromstartthat nobody joins leaks when its parent is cancelled; usebackground(aResource) or aSupervisor.unsafeRunSync()belongs at the program edge only. InsideIOcode it blocks a compute thread and can deadlock.
import zio.*
trait UserRepo:
def find(id: Long): IO[RepoError, Option[User]]
final case class UserService(repo: UserRepo):
def name(id: Long): IO[RepoError, String] =
repo.find(id).someOrFail(RepoError.NotFound(id)).map(_.name)
object UserService:
val layer: ZLayer[UserRepo, Nothing, UserService] =
ZLayer.fromZIO(ZIO.service[UserRepo].map(UserService(_)))
ZIO[R, E, A]carries an environment, a typed error and a result. Expected failures go inE; defects (ZIO.die, exceptions thrown insideZIO.succeed) are for bugs.orDieturns a failure into a defect,refineOrDiekeeps the errors callers can handle.ZIO.succeedtakes its argument by name, so the effect is deferred; code that can throw belongs inZIO.attempt, blocking code inZIO.attemptBlocking.ZLayerwires dependencies;ZIO.acquireReleasewithZIO.scopedmanages resources;ZIO.foreachPar(xs)(f).withParallelism(n)bounds fan-out.
Collections
val totals: Map[String, BigDecimal] =
orders.groupMapReduce(_.customer)(_.amount)(_ + _) // one pass, duplicates summed
val firstLarge = orders.view.filter(_.amount > 1000).map(_.id).headOption // stops at the first match
- Immutable by default.
Listprepends in O(1), but indexing and:+are O(n);Vectorgives near-constant indexing and append. Build locally withListBuffer/ArrayBufferand return an immutable result. .viewmakes a chain lazy;Iteratoris single-pass;LazyListmemoizes.Map.mapValuesandfilterKeysreturn lazy views in 2.13 and 3 that recompute on every access (and are deprecated). Write.view.mapValues(f).toMap.toMapon pairs keeps the last value for a duplicate key without warning.groupBy,groupMapandgroupMapReducekeep or combine them.Arrayequality is reference equality; compare withsameElementsor useArraySeq.foldLeftis stack-safe onList; deepfoldRightover a largeListis where stack overflows come from in older code.
Testing
package com.acme.spool
import java.nio.file.{Files, Path}
class SymlinkedSpoolSuite extends munit.FunSuite:
val dirs = FunFixture[Path](
setup = _ => Files.createTempDirectory("spool"),
teardown = dir => deleteTree(dir),
)
dirs.test("rejects a spool directory that is a symlink") { root =>
val outside = Files.createTempDirectory("outside")
val spool = Files.createSymbolicLink(root.resolve("spool"), outside)
intercept[SecurityException](SpoolWriter(spool).write("a.json", "{}"))
}
- MUnit (
munit.FunSuite):test("name") { ... },assertEquals(obtained, expected)with a diff on failure,intercept[E],FunFixturefor per-test setup.munit-cats-effect'sCatsEffectSuiterunsIOtests (assertIO(io, expected)). - ScalaTest: styles (
AnyFunSuite,AnyFlatSpec,AnyWordSpec) withMatchers(result shouldBe 3,an[IllegalArgumentException] should be thrownBy f());AsyncFunSuiteforFuturecode. - ZIO Test:
object UserServiceSpec extends ZIOSpecDefaultwithsuite(...)(test(...) { ... assertTrue(...) });TestClockcontrols time. - Property tests: ScalaCheck
forAll, throughmunit-scalacheckor ScalaTest'sScalaCheckPropertyChecks. - Without forking, sbt runs suites in parallel inside its own JVM. With
Test / fork := truethey run in one separate JVM, one suite after another unlesstestGroupingsplits them. Suites that share files, ports or global state needTest / parallelExecution := falseor isolation.
Loom Test Runner Adapter
Adapter. Scala uses sbt. A directory with build.sbt is kind scala, and the runner for kind scala is always sbt; loom project detect prints it per package. Mill and Scala CLI projects have no build.sbt, so detection does not see them as Scala.
ingest kinds=scala runner=sbt skills=loom-scala
Single-test command. loom runs it with the package directory, the one holding build.sbt, as the working directory:
sbt 'testOnly {test}'
The test field. The fully qualified suite name, for example com.acme.spool.SymlinkedSpoolSuite; for a ZIO Test object, the object's name as written in source. The command selects a whole suite and passes no per-test filter, so every test in that suite runs.
No-match behaviour. D5 marks sbt "documented": no fixture was captured, and loom's sbt parser was written from sbt's documented output. When the name matches no suite, sbt prints No tests to run for Test / testOnly and exits 0, so the exit code alone would read a misspelled name as a pass. loom reads the runner's summary instead: a name that selects nothing classifies as NotSelected, which fails the freeze ("the runner did not select the test") and fails the completion check ("contract test not selected").
Writing contract tests. Test sources live under src/test/scala/ of the subproject that owns the code, for example core/src/test/scala/com/acme/spool/SymlinkedSpoolSuite.scala for lazy val core = project. Because the adapter selects suites:
- write one suite per contract, holding only that contract's test, and name it after the contract;
- keep the
packageclause equal to the directory path; Scala does not enforce it, and a mismatch makes the suite name differ from what the path suggests; - make sure the root project aggregates the subproject (
.aggregate(core));testOnlyat the root never reaches an unaggregated subproject. A build with no explicit root project gets a default root that aggregates every subproject.
contracts:
- id: rejects-symlinked-spool
file: core/src/test/scala/com/acme/spool/SymlinkedSpoolSuite.scala
test: com.acme.spool.SymlinkedSpoolSuite
scenario: the spool directory is a symlink to a directory outside the data root
rejects: a writer that follows the symlink and writes files outside the data root
Build failures. A contract suite that does not compile yet counts as red at freeze time: loom classifies the compiler failure as BuildFailed. sbt compiles a subproject's whole Test configuration together, so one uncompilable contract suite makes every suite in that subproject BuildFailed until the implementation exists. Freeze and completion run each command under a 300-second limit, and every run starts a fresh sbt: run the single-test command once before freezing so dependency resolution and the first compile happen outside the timed run. A contract that needs a new test dependency edits build.sbt, which must match one of the stage's harness globs.
Anti-Patterns
// Partial accessors throw on the empty case
val user = users.find(_.id == id).get // BAD: NoSuchElementException
val user = users.find(_.id == id).toRight(UserNotFound(id)) // GOOD: the caller handles absence
// Catching everything
try risky() catch { case e: Throwable => log(e) } // BAD: also catches InterruptedException, VM errors
try risky() catch { case NonFatal(e) => log(e) } // GOOD
// Side effect inside IO.pure runs once, when the value is built
val tick = IO.pure(println("tick")) // BAD
val tick = IO.println("tick") // GOOD: runs every time tick runs
// Blocking inside IO on the compute pool
IO(statement.executeQuery(sql)) // BAD: stalls a compute thread
IO.blocking(statement.executeQuery(sql)) // GOOD
Quick swaps: null from Java → Option(javaValue); .head/.last on a possibly empty collection → headOption/lastOption; return inside a lambda → boundary/break or a fold; Await.result in Future code → flatMap/map; a var holding a mutable collection shared between threads or fibers → Ref or AtomicReference; String or Long ids → opaque types; asInstanceOf → a pattern match; implicit conversions → extension methods; List[Any] → an ADT; == between unrelated types (compiles by default) → -language:strictEquality with derives CanEqual.
Expert Practices
Equality and Variance
- Universal equality lets
1 == "1"compile (and returnfalse).-language:strictEqualityplusderives CanEqualon your types turns such comparisons into compile errors. - Variance:
+Afor producers and immutable containers,-Afor consumers (function inputs, encoders). Mutable containers stay invariant; that is whyArray[A]is invariant. - Case classes holding functions or arrays have
equalsby reference for those fields.
Security
- The JVM rules apply: no Java deserialization of untrusted bytes, XML parsers with DTDs disabled,
SecureRandomfor tokens, normalized paths checked against their base. Pekko keeps Java serialization off by default, as Akka does since 2.6; leave it off. - SQL interpolators: doobie's
sql"... where name = $name"and Slick'ssql"... $name"bind parameters. doobie'sFragment.constand Slick's#$namesplice raw text and are injectable. - JSON codecs (circe, jsoniter-scala) decode into known types. Keep class names out of the payload; model polymorphism as a sealed ADT with a discriminator field.
sys.processwith a single string splits on whitespace ("tar -xf " + file). Pass aSeqof arguments.
Performance
@tailrecmakes the compiler verify that a self-recursive call is in tail position; it compiles to a loop.- Generic code over
Int,LongorDoubleboxes every element; Scala 3 does not specialize. Hot numeric loops useArray[Int]orIArray. - Appending to a
Listin a loop is quadratic. Prepend and reverse once, or build withListBuffer/VectorBuilder. - Compile time is a cost: automatic derivation, large macros and very large files slow every build. Split modules along dependency lines so incremental compilation recompiles less.
- Profile with JFR or async-profiler, and benchmark with sbt-jmh; timing loops in tests measure JIT warm-up.
Verification Checklists
Before marking Scala work done:
-
sbt scalafmtCheckAll "scalafixAll --check" testpasses, with warnings as errors (sbt-tpolecat or-Werror) - New behaviour has tests; the new suites appear in the run summary with a non-zero count
- Every
matchover an ADT is exhaustive without guards hiding a variant; no type patterns on erased type arguments - No
.get,.headorasInstanceOfon values that can be absent or of another type - Expected failures are typed (
Either, the effect's error channel); catches useNonFatal - Givens live in companions or are imported with
given; no new implicit conversions - Raw SQL splices (
Fragment.const,#$) never see untrusted input
Effects and concurrency review:
- Side effects are suspended (
IO(...),ZIO.attempt); noIO.purearound effects - Blocking calls in
IO.blocking/ZIO.attemptBlocking; fan-out bounded (parTraverseN,withParallelism) - Resources acquired through
Resource/ZIO.acquireRelease; no fiber started without an owner - No
unsafeRunSync,Await.resultor mutable shared state outsideRefinside effect code - Suites that share files, ports or global state run isolated (
Test / parallelExecutionor forking)