Scala has been polarizing since its first public release in 2004. It promised the best of both worlds — object-oriented and functional programming on the JVM — and in doing so delivered a language so expressive that it could solve problems elegantly, and so complex that it could destroy productivity. The Scala 3 release (formerly Dotty) represents a serious attempt to reckon with that complexity: a complete redesign of the type system foundations, a cleaner syntax, and a principled replacement of the most notoriously confusing feature in the language — implicits.
This is not a beginner tutorial. If you know Python, Go, Java, or Rust well and want to understand what Scala actually offers and what it actually costs, this is that guide.
Why Scala Exists
Martin Odersky’s 2004 insight was that functional programming and object-oriented programming are not opposites — they are complementary. Haskell gives you powerful FP but awkward interop with the outside world. Java gives you industrial-strength OO but verbose, boilerplate-heavy code that fights immutability and higher-order functions. Scala attempts to occupy the intersection: every value is an object, every function is a value, the type system is expressive enough to encode algebraic data types and type classes, and the whole thing runs on the JVM with full Java interop.
That vision attracted serious engineering teams. Twitter rewrote its message queue infrastructure in Scala (Finagle). LinkedIn built Kafka with a Scala API. Apache Spark’s entire programming model is built around Scala collections semantics. Akka — the de facto actor framework for the JVM — is a Scala project. For a certain class of problems — distributed data processing, financial systems, high-throughput event streaming — Scala became the serious engineer’s choice.
It also attracted serious criticism. Compile times that made developers want to switch careers. Type errors spanning 40 lines. Multiple competing standard library approaches. A community split between the pragmatic OO camp and the pure FP camp (exemplified by the Cats vs the everything-else divide). Scala 3 addresses many of these complaints, but the language still demands respect and patience.
Scala 3: The Overhaul
Scala 3 (released May 2021, with the 3.x series now mature and widely adopted) is the most significant revision in the language’s history. It is built on the DOT (Dependent Object Types) calculus, a formally verified theoretical foundation that previous Scala versions approximated but did not implement precisely. The practical consequences are a cleaner language with fewer edge cases, better error messages, and a more coherent story for functional programming.
Optional Braces and Indentation-Based Syntax
Scala 3 introduces significant whitespace — Python-style indentation as an alternative to braces. Both syntaxes are valid and can coexist in the same codebase.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
|
// Scala 2 / Scala 3 with braces (always valid)
def greet(name: String): String = {
val prefix = "Hello"
s"$prefix, $name!"
}
// Scala 3 with indentation syntax (new)
def greet(name: String): String =
val prefix = "Hello"
s"$prefix, $name!"
// Class definition comparison
// Scala 2 style
class Server(host: String, port: Int) {
def start(): Unit = {
println(s"Starting on $host:$port")
}
}
// Scala 3 indentation style
class Server(host: String, port: Int):
def start(): Unit =
println(s"Starting on $host:$port")
|
The indentation syntax is optional — the Scala team deliberately avoided forcing a migration. In practice, many teams continue using braces for consistency with Scala 2 codebases. The indentation syntax shines in scripts and for-heavy functional code where brace nesting was noise.
given and using: Replacing Implicits
This is the most significant semantic change. Scala 2’s implicit keyword did three fundamentally different things:
- Implicit parameters — pass values automatically to functions that declare they need them
- Implicit conversions — automatically convert one type to another
- Implicit class — add methods to existing types
These three use cases had completely different semantics but identical syntax, which made code extremely hard to read. Scala 3 separates them clearly.
Implicit parameters become given/using:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
|
// Scala 2 — implicit parameter passing
trait Ordering[A] {
def compare(x: A, y: A): Int
}
implicit val intOrdering: Ordering[Int] = new Ordering[Int] {
def compare(x: Int, y: Int): Int = x - y
}
def sort[A](list: List[A])(implicit ord: Ordering[A]): List[A] =
list.sortWith((a, b) => ord.compare(a, b) < 0)
// Called as:
sort(List(3, 1, 2))
// Scala 3 — given/using
trait Ordering[A]:
def compare(x: A, y: A): Int
given Ordering[Int] with
def compare(x: Int, y: Int): Int = x - y
def sort[A](list: List[A])(using ord: Ordering[A]): List[A] =
list.sortWith((a, b) => ord.compare(a, b) < 0)
// Syntax is identical at the call site:
sort(List(3, 1, 2))
|
The keyword change is cosmetic in isolation, but the semantics are cleaner: given instances are named (or anonymous if you don’t need to reference them), and using at the declaration site makes it immediately obvious the parameter will be filled automatically.
Type class instances with given:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
|
// Define a type class
trait Show[A]:
def show(a: A): String
// Instances for common types
given Show[Int] with
def show(a: Int): String = a.toString
given Show[String] with
def show(a: String): String = s"\"$a\""
given [A](using showA: Show[A]): Show[List[A]] with
def show(list: List[A]): String =
list.map(showA.show).mkString("[", ", ", "]")
// Use it
def print[A](a: A)(using s: Show[A]): Unit =
println(s.show(a))
print(42) // 42
print("hello") // "hello"
print(List(1, 2, 3)) // [1, 2, 3]
// Summoning a given explicitly
val intShow = summon[Show[Int]]
println(intShow.show(99)) // 99
|
Extension methods replacing implicit classes:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
|
// Scala 2 — implicit class for extension methods
implicit class StringOps(val s: String) extends AnyVal {
def shout: String = s.toUpperCase + "!"
def whisper: String = s.toLowerCase + "..."
}
"hello".shout // "HELLO!"
"WORLD".whisper // "world..."
// Scala 3 — extension methods (first-class syntax)
extension (s: String)
def shout: String = s.toUpperCase + "!"
def whisper: String = s.toLowerCase + "..."
def repeat(n: Int): String = s * n
"hello".shout // "HELLO!"
"WORLD".whisper // "world..."
"ha".repeat(3) // "hahaha"
// Extensions can have type parameters too
extension [A](list: List[A])
def second: Option[A] = list.drop(1).headOption
def penultimate: Option[A] = list.dropRight(1).lastOption
List(1, 2, 3).second // Some(2)
List(1, 2, 3).penultimate // Some(2)
|
enum for Algebraic Data Types
Scala 2 encoded ADTs (Algebraic Data Types) with sealed traits and case classes. This works, but it’s verbose. Scala 3 adds a proper enum construct:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
|
// Scala 3 enum — simple enumeration
enum Direction:
case North, South, East, West
// With parameters — this is where it gets interesting
enum Shape:
case Circle(radius: Double)
case Rectangle(width: Double, height: Double)
case Triangle(base: Double, height: Double)
// Enum methods
enum Shape:
case Circle(radius: Double)
case Rectangle(width: Double, height: Double)
case Triangle(base: Double, height: Double)
def area: Double = this match
case Circle(r) => Math.PI * r * r
case Rectangle(w, h) => w * h
case Triangle(b, h) => 0.5 * b * h
val c = Shape.Circle(5.0)
println(c.area) // 78.539...
// Enums with type parameters (GADT-like)
enum Tree[+A]:
case Leaf
case Branch(value: A, left: Tree[A], right: Tree[A])
def depth[A](tree: Tree[A]): Int = tree match
case Tree.Leaf => 0
case Tree.Branch(_, left, right) => 1 + math.max(depth(left), depth(right))
|
opaque type Aliases
One of the most useful additions for domain modeling. Opaque types give you the type safety of a wrapper without the runtime overhead:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
|
// Without opaque types — stringly typed, no protection
def createUser(email: String, name: String): Unit = ???
createUser("Alice", "alice@example.com") // Bug: args swapped, compiles fine
// With opaque types — the compiler protects you
opaque type Email = String
opaque type Name = String
object Email:
def apply(s: String): Either[String, Email] =
if s.contains("@") then Right(s) else Left(s"Invalid email: $s")
// Extension so you can still call .value or other string ops within the module
extension (e: Email)
def value: String = e
def domain: String = e.split("@").last
object Name:
def apply(s: String): Either[String, Name] =
if s.trim.nonEmpty then Right(s.trim) else Left("Name cannot be empty")
extension (n: Name)
def value: String = n
def createUser(email: Email, name: Name): Unit =
println(s"Creating user: ${name.value} <${email.value}>")
// Now you can't accidentally swap the arguments
val email = Email("alice@example.com").getOrElse(???)
val name = Name("Alice").getOrElse(???)
createUser(email, name) // Fine
// createUser(name, email) // Compile error: type mismatch
|
Outside the defining scope, Email and Name are completely distinct types even though they’re both String at runtime. Zero overhead, full safety.
Union and Intersection Types
Scala 3 introduces proper union types (A | B) and intersection types (A & B):
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
|
// Union types — a value can be one of several types
def parseInput(input: String): Int | Double | String =
input.toIntOption
.map(n => n: Int | Double | String)
.orElse(input.toDoubleOption.map(d => d: Int | Double | String))
.getOrElse(input)
// More useful: error handling without custom types
type Result[A] = A | Error
case class Error(message: String)
def divide(a: Int, b: Int): Int | Error =
if b == 0 then Error("Division by zero") else a / b
// Intersection types — a value must satisfy multiple interfaces
trait Flyable:
def fly(): String
trait Swimmable:
def swim(): String
// A value that is both
def describeAmphibian(creature: Flyable & Swimmable): String =
s"Can fly: ${creature.fly()}, can swim: ${creature.swim()}"
// Practical use: combining capabilities
trait Logger:
def log(msg: String): Unit
trait Metrics:
def record(event: String, value: Long): Unit
def processRequest(deps: Logger & Metrics, request: String): Unit =
deps.log(s"Processing: $request")
deps.record("request.processed", 1L)
|
Match Types
Match types bring type-level computation that was previously only possible through complex implicit-based tricks:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
|
// A type that depends on another type at compile time
type ElementType[X] = X match
case List[a] => a
case Array[a] => a
case String => Char
// The compiler knows the exact return type
def head[X](x: X): ElementType[X] = x match
case list: List[?] => list.head.asInstanceOf[ElementType[X]]
case arr: Array[?] => arr(0).asInstanceOf[ElementType[X]]
case s: String => s.charAt(0).asInstanceOf[ElementType[X]]
val x: Int = head(List(1, 2, 3)) // Compiler knows this is Int
val y: Char = head("hello") // Compiler knows this is Char
|
Scala 3 replaces the complex Scala 2 macro system with a cleaner approach using inline:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
|
// inline functions are expanded at call sites like macros
inline def debug[A](expr: A): A =
println(s"[DEBUG] ${expr}")
expr
// inline if — evaluated at compile time when condition is known
inline def assertNonNegative(n: Int): Unit =
inline if n < 0 then
error("n must be non-negative") // compile-time error
// Scala 3 macros with quotes and splices
import scala.quoted.*
inline def printType[A]: String = ${ printTypeImpl[A] }
def printTypeImpl[A: Type](using Quotes): Expr[String] =
import quotes.reflect.*
val typeRepr = TypeRepr.of[A].show
Expr(typeRepr)
// At call site:
println(printType[List[Int]]) // "scala.collection.immutable.List[scala.Int]"
|
Case Classes and Sealed Traits
Case classes are Scala’s immutable value objects. They’re not just a convenience — they’re a design philosophy. A case class is a product type: it holds a fixed set of named fields, provides structural equality, and is immutable by default.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
|
// A case class is a product type
case class Address(
street: String,
city: String,
country: String,
postalCode: String
)
// What you get for free:
// 1. equals/hashCode based on fields
// 2. toString
// 3. copy method for creating modified copies
// 4. apply on companion object (no 'new' needed)
// 5. unapply for pattern matching
// 6. implements Product and Serializable
val addr1 = Address("123 Main St", "Springfield", "US", "12345")
val addr2 = addr1.copy(city = "Shelbyville")
val addr3 = addr1.copy(city = "Springfield")
println(addr1 == addr3) // true — structural equality
println(addr1 == addr2) // false
// Nested case classes
case class User(
id: Long,
name: String,
email: String,
address: Address
)
val user = User(1L, "Alice", "alice@example.com", addr1)
val movedUser = user.copy(address = user.address.copy(city = "Capital City"))
|
The copy method is crucial for working with immutable data. For deeply nested structures, you’ll eventually want lenses (from Monocle), but for moderate depth copy is idiomatic.
Sealed Traits as Sum Types
A sealed trait combined with case classes forms a sum type — an ADT. The sealed keyword means all subclasses must be defined in the same file, which allows the compiler to perform exhaustiveness checking on pattern matches.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
|
sealed trait PaymentMethod
case class CreditCard(number: String, expiry: String, cvv: String) extends PaymentMethod
case class BankTransfer(iban: String, bic: String) extends PaymentMethod
case class Crypto(address: String, currency: String) extends PaymentMethod
case object Cash extends PaymentMethod
// The compiler enforces exhaustiveness
def processFee(payment: PaymentMethod): Double = payment match
case CreditCard(_, _, _) => 0.029
case BankTransfer(_, _) => 0.005
case Crypto(_, _) => 0.01
case Cash => 0.0
// Remove any case and you get a compile warning/error
// Real-world example: HTTP response ADT
sealed trait HttpResponse[+A]
case class Ok[A](body: A) extends HttpResponse[A]
case class Created[A](body: A, location: String) extends HttpResponse[A]
case class NoContent() extends HttpResponse[Nothing]
case class BadRequest(errors: List[String]) extends HttpResponse[Nothing]
case class NotFound(resource: String) extends HttpResponse[Nothing]
case class InternalServerError(message: String, cause: Throwable) extends HttpResponse[Nothing]
def handleResponse[A](response: HttpResponse[A]): String = response match
case Ok(body) => s"Success: $body"
case Created(body, loc) => s"Created at $loc: $body"
case NoContent() => "No content"
case BadRequest(errors) => s"Bad request: ${errors.mkString(", ")}"
case NotFound(resource) => s"Not found: $resource"
case InternalServerError(msg, _) => s"Server error: $msg"
|
Companion Objects
Every case class gets a companion object automatically. You can add methods and additional factory constructors:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
|
case class UserId(value: Long) extends AnyVal
case class User private (
id: UserId,
name: String,
email: String,
createdAt: java.time.Instant
)
object User:
// Smart constructor with validation
def create(name: String, email: String): Either[String, User] =
for
validName <- if name.trim.nonEmpty then Right(name.trim)
else Left("Name cannot be empty")
validEmail <- if email.contains("@") then Right(email.toLowerCase)
else Left(s"Invalid email: $email")
yield User(
id = UserId(System.nanoTime()),
name = validName,
email = validEmail,
createdAt = java.time.Instant.now()
)
// Ordering for sorting
given Ordering[User] = Ordering.by(_.id.value)
|
The private constructor with a public companion factory is idiomatic for domain objects that need invariants — you get ADT-style construction with validation logic.
Pattern Matching
Pattern matching is central to Scala. It’s not a switch statement — it’s a destructuring, type-narrowing, guard-supporting expression that the compiler verifies for exhaustiveness.
Destructuring
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
|
// Basic value matching
val n = 42
val description = n match
case 0 => "zero"
case 1 => "one"
case x if x < 0 => s"negative: $x" // guard
case x => s"positive: $x" // catch-all with binding
// Case class destructuring
case class Point(x: Double, y: Double)
case class Circle(center: Point, radius: Double)
def describe(shape: AnyRef): String = shape match
case Circle(Point(0, 0), r) => s"Circle at origin, radius $r"
case Circle(Point(x, y), r) => s"Circle at ($x,$y), radius $r"
case Point(0, 0) => "Origin"
case Point(x, 0) => s"On x-axis at $x"
case Point(0, y) => s"On y-axis at $y"
case Point(x, y) => s"Point ($x,$y)"
case _ => "Unknown shape"
// Tuple destructuring
val pair = (1, "hello")
val (num, str) = pair // Direct destructuring in val
// Destructuring in for
val points = List(Point(1, 2), Point(3, 4), Point(0, 0))
for Point(x, y) <- points if x > 0 do
println(s"Positive x: $x")
|
Type Patterns and @ Bindings
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
|
sealed trait Animal
case class Dog(name: String) extends Animal
case class Cat(name: String, indoor: Boolean) extends Animal
case class Bird(species: String) extends Animal
def describe(animal: Animal): String = animal match
case d @ Dog(name) if name.startsWith("Sir") =>
s"Noble dog: ${d.name}"
case Dog(name) =>
s"Dog: $name"
case Cat(name, true) =>
s"Indoor cat: $name"
case Cat(name, false) =>
s"Outdoor cat: $name"
case b: Bird =>
s"Bird species: ${b.species}"
// @ binding — bind the whole match AND destructure
val animals = List(Dog("Sir Barks"), Cat("Whiskers", true), Bird("Robin"))
animals.foreach:
case a @ Cat(_, true) => println(s"Indoor cat (full obj: $a)")
case a => println(s"Other animal: $a")
|
Pattern matching isn’t limited to case classes. Any object with an unapply method can be used in a pattern:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
|
// Custom extractor for email validation
object Email:
def unapply(s: String): Option[(String, String)] =
s.split("@") match
case Array(local, domain) if domain.contains(".") => Some((local, domain))
case _ => None
val input = "alice@example.com"
input match
case Email(local, domain) => println(s"Valid email: local=$local, domain=$domain")
case _ => println("Not an email")
// Extractor for URL parsing
object URL:
def unapply(s: String): Option[(String, String, String)] =
val pattern = "^(https?)://([^/]+)(/.*)?$".r
s match
case pattern(scheme, host, path) => Some((scheme, host, Option(path).getOrElse("/")))
case _ => None
"https://api.example.com/v1/users" match
case URL(scheme, host, path) => println(s"$scheme request to $host$path")
case _ => println("Not a URL")
// Boolean extractor (unapply returns Boolean)
object Positive:
def unapply(n: Int): Boolean = n > 0
42 match
case Positive() => println("positive")
case _ => println("non-positive")
// Sequence extractor
object Cons:
def unapplySeq[A](list: List[A]): Option[(A, List[A])] =
list match
case head :: tail => Some((head, tail))
case Nil => None
List(1, 2, 3) match
case Cons(head, rest) => println(s"head=$head, rest=$rest")
case _ => println("empty")
|
Pattern Matching on Types (Type Tests)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
|
import scala.reflect.ClassTag
def process(value: Any): String = value match
case n: Int => s"Integer: $n"
case s: String => s"String of length ${s.length}: $s"
case list: List[?] => s"List with ${list.size} elements"
case map: Map[?, ?] => s"Map with ${map.size} entries"
case null => "null"
case other => s"Unknown: ${other.getClass.getName}"
// Note: JVM type erasure means List[Int] and List[String] are indistinguishable
// at runtime unless you use ClassTag or TypeTag. This is a real footgun.
def processTyped[A: ClassTag](list: List[Any]): List[A] =
list.collect { case a: A => a }
|
The Type System
Scala’s type system is one of the most expressive on the JVM. Understanding it is the difference between fighting the compiler and working with it.
Variance
Variance describes how parameterized types relate to each other when their type parameters have a subtype relationship.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
|
// Invariant (default) — List[Dog] is NOT a List[Animal]
class Box[A](value: A)
// Covariant (+A) — if Dog <: Animal, then Producer[Dog] <: Producer[Animal]
// Safe for producers (things you only read from)
trait Producer[+A]:
def get: A
class DogProducer extends Producer[Dog]:
def get: Dog = Dog("Rex")
val animalProducer: Producer[Animal] = DogProducer() // OK — covariant
// Contravariant (-A) — if Dog <: Animal, then Consumer[Animal] <: Consumer[Dog]
// Safe for consumers (things you only write to)
trait Consumer[-A]:
def consume(a: A): Unit
class AnimalConsumer extends Consumer[Animal]:
def consume(a: Animal): Unit = println(s"Got $a")
val dogConsumer: Consumer[Dog] = AnimalConsumer() // OK — contravariant
// The classic example: Function1[-A, +B]
// A function from Animal => String can be used where Dog => String is expected
// (because it can handle any Dog since Dog is an Animal)
val dogDescriber: Dog => String = (a: Animal) => a.toString // OK
|
Getting variance wrong causes compile errors, which is exactly what you want — the compiler is enforcing valid substitution.
Higher-Kinded Types
Higher-kinded types (HKTs) are types that take other types as parameters. They’re what make type classes like Functor and Monad expressible in Scala.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
|
// A regular type constructor: List[A], Option[A]
// A higher-kinded type: F[_] — a type that takes a type and produces a type
// Type class for things you can map over
trait Functor[F[_]]:
def map[A, B](fa: F[A])(f: A => B): F[B]
// Instances
given Functor[List] with
def map[A, B](fa: List[A])(f: A => B): List[B] = fa.map(f)
given Functor[Option] with
def map[A, B](fa: Option[A])(f: A => B): Option[B] = fa.map(f)
// A custom type
case class Box[A](value: A)
given Functor[Box] with
def map[A, B](fa: Box[A])(f: A => B): Box[B] = Box(f(fa.value))
// A function that works for any F with a Functor instance
def doubleAll[F[_]: Functor](fa: F[Int]): F[Int] =
summon[Functor[F]].map(fa)(_ * 2)
doubleAll(List(1, 2, 3)) // List(2, 4, 6)
doubleAll(Option(5)) // Some(10)
doubleAll(Box(7)) // Box(14)
|
Type Classes: The given/using Pattern in Full
Type classes are how you achieve ad-hoc polymorphism — adding behavior to types you don’t control without modifying them.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
|
// Step 1: Define the type class
trait JsonEncoder[A]:
def encode(a: A): String
// Step 2: Provide instances
given JsonEncoder[Int] with
def encode(a: Int): String = a.toString
given JsonEncoder[String] with
def encode(a: String): String = s""""${a.replace("\"", "\\\"")}""""
given JsonEncoder[Boolean] with
def encode(a: Boolean): String = a.toString
given [A](using enc: JsonEncoder[A]): JsonEncoder[List[A]] with
def encode(list: List[A]): String =
list.map(enc.encode).mkString("[", ",", "]")
given [A, B](using encA: JsonEncoder[A], encB: JsonEncoder[B]): JsonEncoder[(A, B)] with
def encode(pair: (A, B)): String =
s"[${encA.encode(pair._1)},${encB.encode(pair._2)}]"
// Step 3: Use the type class (with context bound syntax sugar)
def toJson[A: JsonEncoder](a: A): String =
summon[JsonEncoder[A]].encode(a)
// Extension method syntax — the most ergonomic approach
extension [A: JsonEncoder](a: A)
def toJson: String = summon[JsonEncoder[A]].encode(a)
// Step 4: Derive instances for case classes (Scala 3 derivation)
import scala.deriving.Mirror
inline def derived[A](using m: Mirror.Of[A]): JsonEncoder[A] = ??? // full impl omitted
case class User(name: String, age: Int) derives JsonEncoder // auto-derived!
42.toJson // "42"
"hello".toJson // "\"hello\""
List(1, 2, 3).toJson // "[1,2,3]"
|
The derives keyword in Scala 3 makes automatic type class derivation first-class. Libraries like Circe, Tapir, and others use this extensively.
Collections
The Scala collections library is one of the most carefully designed in any language. The key distinction is immutable vs. mutable — immutable collections are in scala.collection.immutable (imported by default), mutable are in scala.collection.mutable.
The Core Operations
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
|
val numbers = List(1, 2, 3, 4, 5, 6, 7, 8, 9, 10)
// map — transform each element
val doubled = numbers.map(_ * 2) // List(2, 4, 6, 8, 10, 12, 14, 16, 18, 20)
// filter — keep elements matching predicate
val evens = numbers.filter(_ % 2 == 0) // List(2, 4, 6, 8, 10)
// flatMap — map then flatten
val nested = List(List(1, 2), List(3, 4), List(5))
val flat = nested.flatMap(identity) // List(1, 2, 3, 4, 5)
// Real flatMap use — generating combinations
val suits = List("♠", "♥", "♦", "♣")
val ranks = List("A", "K", "Q", "J")
val cards = suits.flatMap(s => ranks.map(r => s"$r$s"))
// List("A♠", "K♠", ..., "J♣")
// fold — aggregate with an accumulator
val sum = numbers.foldLeft(0)(_ + _) // 55
val product = numbers.foldLeft(1)(_ * _) // 3628800
// collect — filter AND transform in one pass (uses partial functions)
val strings = List("1", "two", "3", "four", "5")
val parsed = strings.collect {
case s if s.toIntOption.isDefined => s.toInt
} // List(1, 3, 5)
// groupBy — partition into a Map
val byParity = numbers.groupBy(n => if n % 2 == 0 then "even" else "odd")
// Map("odd" -> List(1, 3, 5, 7, 9), "even" -> List(2, 4, 6, 8, 10))
// partition — split into two lists
val (small, large) = numbers.partition(_ <= 5)
// small = List(1,2,3,4,5), large = List(6,7,8,9,10)
// zip and unzip
val letters = List('a', 'b', 'c')
val zipped = numbers.take(3).zip(letters) // List((1,'a'), (2,'b'), (3,'c'))
val (nums, chars) = zipped.unzip
// scanLeft — like foldLeft but keeps intermediate results
val runningSum = numbers.scanLeft(0)(_ + _)
// List(0, 1, 3, 6, 10, 15, 21, 28, 36, 45, 55)
|
For-Comprehensions as flatMap Sugar
For-comprehensions in Scala are not loops — they’re syntactic sugar for flatMap, map, filter, and foreach. This is crucial to understand.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
|
// These are identical:
val result1 = List(1, 2, 3).flatMap(x =>
List(10, 20).flatMap(y =>
List(100, 200).map(z =>
x + y + z
)
)
)
val result2 = for
x <- List(1, 2, 3)
y <- List(10, 20)
z <- List(100, 200)
yield x + y + z
// result1 == result2 == List(111, 211, 121, 221, 131, 231, ...)
// For-comprehension with guards (translates to withFilter)
val pairs = for
x <- 1 to 10
y <- 1 to 10
if x < y
if (x + y) % 3 == 0
yield (x, y)
// The magic: for-comprehensions work on ANY monad
// With Option:
def findUser(id: Int): Option[String] = if id == 1 then Some("Alice") else None
def findEmail(user: String): Option[String] = if user == "Alice" then Some("alice@example.com") else None
def findDomain(email: String): Option[String] = email.split("@").lastOption
val domain: Option[String] = for
user <- findUser(1)
email <- findEmail(user)
domain <- findDomain(email)
yield domain
// Some("example.com")
// If ANY step returns None, the whole thing short-circuits to None
val nothing: Option[String] = for
user <- findUser(99) // None — everything after this is skipped
email <- findEmail(user)
yield email
// None
// With Either for error handling:
def parseId(s: String): Either[String, Int] =
s.toIntOption.toRight(s"Not a number: $s")
def loadUser(id: Int): Either[String, String] =
if id > 0 then Right(s"User#$id") else Left(s"Invalid id: $id")
val user: Either[String, String] = for
id <- parseId("42")
user <- loadUser(id)
yield user
// Right("User#42")
|
LazyList for Infinite Sequences
LazyList (formerly Stream) is a lazy, potentially infinite sequence. Elements are computed on demand and cached.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
|
// Infinite sequence of natural numbers
val naturals: LazyList[Int] = LazyList.from(1)
// Take only what you need
naturals.take(5).toList // List(1, 2, 3, 4, 5)
// Fibonacci sequence
def fibs: LazyList[BigInt] =
def go(a: BigInt, b: BigInt): LazyList[BigInt] =
a #:: go(b, a + b)
go(0, 1)
fibs.take(15).toList // List(0, 1, 1, 2, 3, 5, 8, 13, 21, 34, 55, 89, 144, 233, 377)
// Primes via sieve
def sieve(s: LazyList[Int]): LazyList[Int] =
s.head #:: sieve(s.tail.filter(_ % s.head != 0))
val primes = sieve(LazyList.from(2))
primes.take(10).toList // List(2, 3, 5, 7, 11, 13, 17, 19, 23, 29)
// Practical use: paginated API results
def fetchPage(page: Int): List[String] = ??? // HTTP call
lazy val allResults: LazyList[String] =
LazyList.unfold(1) { page =>
val results = fetchPage(page)
if results.isEmpty then None
else Some((results, page + 1))
}.flatten
// This only makes HTTP calls as you consume the stream
allResults.take(100).toList
|
Cats
Cats is the foundational functional programming library for Scala. Its name is a portmanteau of “category theory” — it provides type class abstractions that, in category theory terms, structure how computations compose.
You do not need a PhD in category theory to use Cats effectively. You need to understand what the abstractions do, not why a mathematician named them what they did.
What Cats Provides
The core value proposition: Cats defines principled, composable abstractions for common programming patterns. Instead of every library inventing its own effect type, its own error handling strategy, and its own concurrency primitives, Cats provides standard interfaces that everything can implement.
Functor, Applicative, Monad
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
|
import cats._
import cats.implicits._
// Functor — for things you can map over
// You already know this: List, Option, Either are all Functors
val opt: Option[Int] = Some(5)
val doubled = opt.map(_ * 2) // Some(10)
// With Functor type class directly:
def transform[F[_]: Functor](fa: F[Int]): F[Int] =
fa.map(_ * 2)
transform(List(1, 2, 3)) // List(2, 4, 6)
transform(Option(5)) // Some(10)
transform(Either.right[String, Int](5)) // Right(10)
// Applicative — for applying wrapped functions to wrapped values
// and for lifting values into a context (pure)
val wrappedFn: Option[Int => String] = Some(_.toString)
val wrappedVal: Option[Int] = Some(42)
val result: Option[String] = wrappedFn.ap(wrappedVal) // Some("42")
// More useful — combining independent effects
val firstName: Option[String] = Some("Alice")
val lastName: Option[String] = Some("Smith")
val age: Option[Int] = Some(30)
// mapN applies a function to multiple independent Option values
val user = (firstName, lastName, age).mapN { (f, l, a) =>
s"$f $l, age $a"
} // Some("Alice Smith, age 30")
// If any is None, result is None
val noAge: Option[Int] = None
val broken = (firstName, lastName, noAge).mapN { (f, l, a) =>
s"$f $l, age $a"
} // None
// Monad — for sequencing dependent effects (the flatMap abstraction)
// For-comprehensions work on any Monad
// traverse — the inverse of sequence
val opts: List[Option[Int]] = List(Some(1), Some(2), Some(3))
val result2: Option[List[Int]] = opts.sequence // Some(List(1, 2, 3))
val withNone: List[Option[Int]] = List(Some(1), None, Some(3))
val result3: Option[List[Int]] = withNone.sequence // None — short-circuits
// traverse is map then sequence
def safeDiv(n: Int, d: Int): Option[Int] =
if d == 0 then None else Some(n / d)
val dividends = List(10, 20, 30)
val results: Option[List[Int]] = dividends.traverse(safeDiv(_, 2))
// Some(List(5, 10, 15))
|
Either for Error Handling
Either is the bread-and-butter of functional error handling in Scala. By convention, Left is the error and Right is the success value.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
|
import cats.syntax.either._
type AppError = String // in real code, use a sealed trait
def validateAge(age: Int): Either[AppError, Int] =
if age >= 0 && age < 150 then Right(age)
else Left(s"Invalid age: $age")
def validateName(name: String): Either[AppError, String] =
if name.trim.nonEmpty then Right(name.trim)
else Left("Name cannot be empty")
def validateEmail(email: String): Either[AppError, String] =
if email.contains("@") then Right(email.toLowerCase)
else Left(s"Invalid email: $email")
// Chain validations — fails on first error
def createUser(name: String, email: String, age: Int): Either[AppError, String] =
for
validName <- validateName(name)
validEmail <- validateEmail(email)
validAge <- validateAge(age)
yield s"$validName <$validEmail> age $validAge"
createUser("Alice", "alice@example.com", 30) // Right("Alice <alice@example.com> age 30")
createUser("", "alice@example.com", 30) // Left("Name cannot be empty")
// EitherT — transformer for Either inside another effect
import cats.data.EitherT
import cats.effect.IO
def fetchUser(id: Int): IO[Either[AppError, String]] = IO.pure(Right(s"User#$id"))
def fetchProfile(user: String): IO[Either[AppError, Map[String, String]]] =
IO.pure(Right(Map("bio" -> "Engineer")))
// Without EitherT: nested for-comprehensions, ugly
val result = for
userOrErr <- fetchUser(1)
profileOrErr <- userOrErr.traverse(fetchProfile)
yield profileOrErr
// With EitherT: clean sequencing
val cleanResult: EitherT[IO, AppError, Map[String, String]] = for
user <- EitherT(fetchUser(1))
profile <- EitherT(fetchProfile(user))
yield profile
cleanResult.value // IO[Either[AppError, Map[String, String]]]
|
Validated for Accumulating Errors
Either fails fast — you get the first error. Validated accumulates all errors. This is what you want for form validation or request validation.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
|
import cats.data.{Validated, ValidatedNel, NonEmptyList}
import cats.syntax.validated._
type ValidationError = String
type Validated[A] = ValidatedNel[ValidationError, A] // Nel = NonEmptyList
def validateName(name: String): ValidatedNel[ValidationError, String] =
if name.trim.nonEmpty then name.trim.validNel
else "Name cannot be empty".invalidNel
def validateEmail(email: String): ValidatedNel[ValidationError, String] =
if email.contains("@") then email.validNel
else s"Invalid email: $email".invalidNel
def validateAge(age: Int): ValidatedNel[ValidationError, Int] =
if age >= 18 then age.validNel
else s"Must be 18 or older, got $age".invalidNel
case class RegisterRequest(name: String, email: String, age: Int)
def validate(req: RegisterRequest): ValidatedNel[ValidationError, RegisterRequest] =
(
validateName(req.name),
validateEmail(req.email),
validateAge(req.age)
).mapN(RegisterRequest.apply)
// All errors collected:
validate(RegisterRequest("", "not-an-email", 15)) match
case Validated.Valid(req) => println(s"Valid: $req")
case Validated.Invalid(errors) =>
println(s"Errors: ${errors.toList.mkString(", ")}")
// Errors: Name cannot be empty, Invalid email: not-an-email, Must be 18 or older, got 15
// One error:
validate(RegisterRequest("Alice", "alice@example.com", 15)) match
case Validated.Invalid(errors) =>
println(s"Errors: ${errors.toList.mkString(", ")}")
// Errors: Must be 18 or older, got 15
|
cats-effect 3.x and the IO Monad
cats-effect provides the IO type — a referentially transparent description of side effects. An IO[A] is a value that describes a computation that will produce an A when executed, potentially performing side effects along the way.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
|
import cats.effect._
import cats.effect.std.Queue
import scala.concurrent.duration._
// IO is lazy — this doesn't print anything yet
val printHello: IO[Unit] = IO.println("Hello, World!")
// Chain IO actions
val program: IO[Unit] = for
_ <- IO.println("Starting...")
n <- IO(scala.util.Random.nextInt(100))
_ <- IO.println(s"Random number: $n")
_ <- IO.sleep(1.second)
_ <- IO.println("Done!")
yield ()
// Run at the "end of the world" (in IOApp)
object Main extends IOApp.Simple:
def run: IO[Unit] = program
// Error handling with IO
val riskyIO: IO[Int] = IO.raiseError(new RuntimeException("Boom!"))
val handled: IO[Int] = riskyIO.handleErrorWith {
case _: RuntimeException => IO.pure(-1)
case e => IO.raiseError(e)
}
// Concurrency — fibers
val concurrent: IO[Unit] = for
fiber1 <- IO.println("Fiber 1 starting").flatMap(_ => IO.sleep(2.seconds)).flatMap(_ => IO.println("Fiber 1 done")).start
fiber2 <- IO.println("Fiber 2 starting").flatMap(_ => IO.sleep(1.second)).flatMap(_ => IO.println("Fiber 2 done")).start
_ <- fiber2.join
_ <- fiber1.join
yield ()
// Resource management — bracket pattern
def openFile(path: String): IO[java.io.FileInputStream] =
IO(new java.io.FileInputStream(path))
def closeFile(fis: java.io.FileInputStream): IO[Unit] =
IO(fis.close())
val fileResource: Resource[IO, java.io.FileInputStream] =
Resource.make(openFile("/etc/hosts"))(closeFile)
// Resource.use guarantees the release action runs even if the use action throws
fileResource.use { fis =>
IO(fis.available()).flatMap(n => IO.println(s"File has $n bytes available"))
}
|
The key insight about cats-effect is that IO is a value — you can pass it around, combine it, inspect it, and reason about it without executing it. The execution only happens at the boundary of your program.
ZIO
ZIO is the other major effect system for Scala. Where cats-effect grew from the Cats ecosystem and has a Haskell-influenced design, ZIO was built from scratch by John De Goes with a different set of priorities: developer experience, explicit dependencies, and a batteries-included approach.
The ZIO[R, E, A] Type
The fundamental type is ZIO[R, E, A]:
R — the environment (dependencies) the effect requires
E — the error type (what can go wrong)
A — the success type (what you get on success)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
|
import zio._
import zio.Console._
// Type aliases that ZIO provides:
// Task[A] = ZIO[Any, Throwable, A] — can fail with any Throwable, no requirements
// UIO[A] = ZIO[Any, Nothing, A] — cannot fail, no requirements
// URIO[R, A] = ZIO[R, Nothing, A] — cannot fail, requires R
// RIO[R, A] = ZIO[R, Throwable, A] — can fail with Throwable, requires R
// IO[E, A] = ZIO[Any, E, A] — can fail with E, no requirements
val helloWorld: Task[Unit] = ZIO.succeed(println("Hello, ZIO!"))
val safeHello: UIO[Unit] = ZIO.succeedBlocking(println("Hello from thread pool"))
// Error handling is explicit in the type
val mayFail: IO[String, Int] = ZIO.fail("Something went wrong")
val recovered: UIO[Int] = mayFail.catchAll(_ => ZIO.succeed(-1))
// For-comprehensions work
val program: Task[Unit] = for
_ <- printLine("What's your name?")
name <- readLine
_ <- printLine(s"Hello, $name!")
yield ()
// Running ZIO programs
object Main extends ZIOAppDefault:
def run = program
|
ZIO Layers: Dependency Injection
This is ZIO’s killer feature. Instead of constructor injection or implicits, ZIO uses ZLayer — composable, testable dependency graphs.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
|
import zio._
// Define services as interfaces
trait UserRepository:
def findById(id: Long): Task[Option[User]]
def save(user: User): Task[User]
trait EmailService:
def sendWelcome(email: String): Task[Unit]
// Implementations
case class PostgresUserRepository(conn: java.sql.Connection) extends UserRepository:
def findById(id: Long): Task[Option[User]] = ZIO.attempt {
// actual DB query
???
}
def save(user: User): Task[User] = ZIO.attempt {
// actual DB insert
???
}
case class SendgridEmailService(apiKey: String) extends EmailService:
def sendWelcome(email: String): Task[Unit] = ZIO.attempt {
// actual HTTP call
???
}
// Define layers
val postgresLayer: ZLayer[Any, Throwable, UserRepository] =
ZLayer.scoped {
ZIO.acquireRelease(
ZIO.attempt(new java.sql.DriverManager.getConnection("jdbc:postgresql://localhost/db"))
)(conn => ZIO.succeed(conn.close()))
.map(PostgresUserRepository.apply)
}
val emailLayer: ZLayer[Any, Throwable, EmailService] =
ZLayer.fromZIO(
ZIO.attempt(sys.env("SENDGRID_API_KEY"))
.map(SendgridEmailService.apply)
)
// Business logic — depends on both services via ZIO environment
def registerUser(name: String, email: String): ZIO[UserRepository & EmailService, Throwable, User] =
for
repo <- ZIO.service[UserRepository]
mailer <- ZIO.service[EmailService]
user = User(id = 0L, name = name, email = email)
saved <- repo.save(user)
_ <- mailer.sendWelcome(saved.email)
yield saved
// Wire it all together
val appLayer = postgresLayer ++ emailLayer
val main = registerUser("Alice", "alice@example.com")
.provide(appLayer)
|
This approach has a significant advantage over DI frameworks: the type system tells you exactly what a function needs. If you forget to provide a layer, it’s a compile error, not a runtime NullPointerException.
ZIO vs cats-effect
This is a genuine debate in the Scala community. Both are excellent; the choice depends on context.
┌─────────────────────┬──────────────────────────┬──────────────────────────┐
│ Feature │ cats-effect │ ZIO │
├─────────────────────┼──────────────────────────┼──────────────────────────┤
│ Error handling │ IO[A] — errors in │ ZIO[R,E,A] — errors in │
│ │ Throwable only │ the type, checked │
├─────────────────────┼──────────────────────────┼──────────────────────────┤
│ Dependency inj. │ Reader monad / implicits │ ZLayer — first-class │
├─────────────────────┼──────────────────────────┼──────────────────────────┤
│ Ecosystem │ Cats ecosystem, typelevel│ ZIO ecosystem, batteries │
│ │ stack (http4s, doobie) │ included (ZIO HTTP, etc) │
├─────────────────────┼──────────────────────────┼──────────────────────────┤
│ Learning curve │ Category theory concepts │ Opinionated, more docs │
├─────────────────────┼──────────────────────────┼──────────────────────────┤
│ Compile times │ Similar │ Similar │
├─────────────────────┼──────────────────────────┼──────────────────────────┤
│ Testing │ cats-effect-testing │ ZIO Test — excellent │
├─────────────────────┼──────────────────────────┼──────────────────────────┤
│ Streaming │ fs2 │ ZIO Streams │
└─────────────────────┴──────────────────────────┴──────────────────────────┘
ZIO Fibers and Concurrency
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
|
import zio._
import zio.Duration._
// Fibers are lightweight threads — you can have millions of them
val fib1 = ZIO.sleep(2.seconds) *> ZIO.succeed("Fiber 1 done")
val fib2 = ZIO.sleep(1.second) *> ZIO.succeed("Fiber 2 done")
// Run concurrently
val concurrent = for
f1 <- fib1.fork
f2 <- fib2.fork
r2 <- f2.join // Wait for fiber 2
r1 <- f1.join // Wait for fiber 1
_ <- ZIO.succeed(println(s"$r1, $r2"))
yield ()
// raceAll — run multiple effects, take first winner
val raceResult = ZIO.raceAll(
ZIO.sleep(3.seconds) *> ZIO.succeed(1),
ZIO.sleep(1.second) *> ZIO.succeed(2),
ZIO.sleep(2.seconds) *> ZIO.succeed(3)
) // Returns 2
// Par operators — run independently in parallel
val par = ZIO.collectAllPar(List(
ZIO.sleep(1.second) *> ZIO.succeed(1),
ZIO.sleep(1.second) *> ZIO.succeed(2),
ZIO.sleep(1.second) *> ZIO.succeed(3)
)) // Takes 1 second total, returns List(1, 2, 3)
|
ZIO STM: Software Transactional Memory
STM is one of ZIO’s most compelling features — a way to do concurrent state management without locks, and without the deadlocks that come with locks.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
|
import zio.stm._
// STM transactions are composable and automatically retried on conflict
case class BankAccount(balance: TRef[Double])
object BankAccount:
def make(initial: Double): USTM[BankAccount] =
TRef.make(initial).map(BankAccount.apply)
def transfer(from: BankAccount, to: BankAccount, amount: Double): USTM[Unit] =
for
fromBalance <- from.balance.get
_ <- STM.check(fromBalance >= amount) // Retry if insufficient
_ <- from.balance.update(_ - amount)
_ <- to.balance.update(_ + amount)
yield ()
// The transfer is atomic — either both updates happen or neither does
// No possibility of partial updates, no deadlocks
val program = for
alice <- BankAccount.make(1000.0).commit
bob <- BankAccount.make(500.0).commit
_ <- transfer(alice, bob, 250.0).commit
aliceBal <- alice.balance.get.commit
bobBal <- bob.balance.get.commit
_ <- ZIO.succeed(println(s"Alice: $aliceBal, Bob: $bobBal"))
yield ()
// Alice: 750.0, Bob: 750.0
|
ZIO Streams
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
|
import zio.stream._
// Create streams
val stream1: ZStream[Any, Nothing, Int] = ZStream(1, 2, 3, 4, 5)
val infinite: ZStream[Any, Nothing, Int] = ZStream.iterate(0)(_ + 1)
// Transform streams
val processed = stream1
.map(_ * 2)
.filter(_ > 4)
.take(2)
// Collect from stream
val result: ZIO[Any, Nothing, List[Int]] = processed.runCollect.map(_.toList)
// Real-world: read from file, process, write to another file
val pipeline: ZIO[Any, Throwable, Long] =
ZStream.fromFileName("input.csv")
.via(ZPipeline.utf8Decode)
.via(ZPipeline.splitLines)
.drop(1) // Skip header
.map(_.split(","))
.filter(_.length == 3)
.map(row => s"${row(0).trim},${row(2).trim}\n")
.via(ZPipeline.utf8Encode)
.run(ZSink.fromFileName("output.csv"))
// Merge streams
val s1 = ZStream(1, 2, 3).schedule(Schedule.spaced(1.second))
val s2 = ZStream(4, 5, 6).schedule(Schedule.spaced(500.millis))
val merged = s1.merge(s2) // Elements interleaved by timing
|
sbt: The Standard
sbt (Scala Build Tool) is the most widely used Scala build tool. It uses a DSL built on Scala itself, which is simultaneously its greatest strength and its most confusing aspect.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
|
// build.sbt — a typical configuration
val scala3Version = "3.4.1"
lazy val root = project
.in(file("."))
.settings(
name := "my-service",
version := "0.1.0",
scalaVersion := scala3Version,
libraryDependencies ++= Seq(
// Cats effect
"org.typelevel" %% "cats-effect" % "3.5.4",
// HTTP server
"org.http4s" %% "http4s-ember-server" % "0.23.26",
"org.http4s" %% "http4s-circe" % "0.23.26",
// JSON
"io.circe" %% "circe-generic" % "0.14.7",
"io.circe" %% "circe-parser" % "0.14.7",
// Database
"org.tpolecat" %% "doobie-core" % "1.0.0-RC4",
"org.tpolecat" %% "doobie-postgres" % "1.0.0-RC4",
// Testing
"org.scalameta" %% "munit" % "1.0.0" % Test,
"org.typelevel" %% "munit-cats-effect-3" % "1.0.7" % Test,
),
// Compiler options for strict mode
scalacOptions ++= Seq(
"-deprecation",
"-feature",
"-Wunused:all",
"-Wvalue-discard",
),
)
// Multi-project build
lazy val core = project
.in(file("modules/core"))
.settings(scalaVersion := scala3Version)
lazy val api = project
.in(file("modules/api"))
.dependsOn(core)
.settings(scalaVersion := scala3Version)
lazy val worker = project
.in(file("modules/worker"))
.dependsOn(core)
.settings(scalaVersion := scala3Version)
|
Key sbt commands:
1
2
3
4
5
6
7
8
|
sbt compile # Compile
sbt test # Run tests
sbt run # Run main class
sbt "testOnly *UserSpec" # Run specific test
sbt assembly # Create fat JAR (with sbt-assembly plugin)
sbt ~compile # Watch mode — recompile on file changes
sbt console # REPL with project classpath loaded
sbt reload # Reload build after changing build.sbt
|
project/plugins.sbt:
1
2
3
4
5
6
|
// Common plugins
addSbtPlugin("com.eed3si9n" % "sbt-assembly" % "2.2.0")
addSbtPlugin("org.scalameta" % "sbt-scalafmt" % "2.5.2")
addSbtPlugin("ch.epfl.scala" % "sbt-scalafix" % "0.12.1")
addSbtPlugin("com.github.sbt" % "sbt-native-packager" % "1.10.0")
addSbtPlugin("org.scoverage" % "sbt-scoverage" % "2.0.11")
|
Mill: The Modern Alternative
Mill was created by Li Haoyi (author of Ammonite and the Requests/OS-Lib libraries) as a simpler, faster alternative to sbt. It’s Scala code all the way down — no DSL tricks.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
|
// build.sc
import mill._, scalalib._
object core extends ScalaModule {
def scalaVersion = "3.4.1"
def ivyDeps = Agg(
ivy"org.typelevel::cats-effect:3.5.4",
ivy"org.http4s::http4s-ember-server:0.23.26",
)
object test extends ScalaTests with TestModule.Munit {
def ivyDeps = Agg(
ivy"org.scalameta::munit:1.0.0",
)
}
}
object api extends ScalaModule {
def scalaVersion = "3.4.1"
def moduleDeps = Seq(core)
def ivyDeps = Agg(
ivy"org.http4s::http4s-circe:0.23.26",
)
}
|
Mill’s advantages over sbt:
- Faster incremental compilation (smarter caching)
- Simpler mental model — no
Setting[T] vs Task[T] confusion
- Better IDE support for build files
mill --watch for file watching
1
2
3
4
5
|
mill core.compile # Compile core module
mill core.test # Run core tests
mill api.run # Run api module
mill __.test # Run all tests (glob syntax)
mill show core.classpath # Inspect computed values
|
scala-cli: The Script Runner
scala-cli fills a gap that sbt and Mill can’t fill: quick scripts and single-file programs without a project structure.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
|
//> using scala 3.4.1
//> using dep org.typelevel::cats-effect:3.5.4
//> using dep com.lihaoyi::requests:0.8.0
//> using dep com.lihaoyi::ujson:3.1.2
import cats.effect._
import requests._
object HttpCheck extends IOApp.Simple:
def run: IO[Unit] =
IO.blocking {
val response = get("https://httpbin.org/get")
val json = ujson.read(response.text())
println(s"Status: ${response.statusCode}")
println(s"Origin: ${json("origin")}")
}
|
1
2
3
4
5
|
scala-cli run check.sc # Run a script
scala-cli run check.sc --watch # Watch mode
scala-cli test MySpec.scala # Run tests
scala-cli package check.sc -o check # Package as executable
scala-cli repl --dep org.typelevel::cats-effect:3.5.4 # REPL with deps
|
scala-cli is particularly useful for:
- Devops tooling and automation scripts (replace bash with type-safe Scala)
- Quick experiments with a library
- Single-binary tools (it can produce GraalVM native images)
Real-World Use Cases
Apache Spark
Spark’s entire API is built around Scala’s collection semantics. The Dataset[T] API is type-safe Scala at its finest:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
|
import org.apache.spark.sql.{SparkSession, Dataset}
import org.apache.spark.sql.functions._
case class LogEntry(
timestamp: java.sql.Timestamp,
level: String,
service: String,
message: String
)
val spark = SparkSession.builder()
.appName("Log Analysis")
.getOrCreate()
import spark.implicits._
val logs: Dataset[LogEntry] = spark.read
.parquet("s3://my-bucket/logs/2026-04-10/")
.as[LogEntry]
// This looks like Scala collections, but runs on a cluster
val errorsByService = logs
.filter(_.level == "ERROR")
.groupByKey(_.service)
.count()
.orderBy(desc("count(1)"))
.limit(10)
errorsByService.show()
|
Spark is the single largest driver of Scala adoption. If you work with large-scale data processing, Spark’s Scala API is significantly more expressive than its Python API (PySpark), and you get type safety that catches errors at compile time rather than after a 2-hour job run.
Kafka Streams and Akka
The original Kafka producers/consumers had a Scala API. Akka — the actor framework — is the basis for everything from game servers to financial systems:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
|
import akka.actor.typed._
import akka.actor.typed.scaladsl._
// Typed actors — message types are part of the actor type
sealed trait OrderCommand
case class PlaceOrder(item: String, qty: Int, replyTo: ActorRef[OrderResult]) extends OrderCommand
case class CancelOrder(orderId: String, replyTo: ActorRef[OrderResult]) extends OrderCommand
sealed trait OrderResult
case class OrderPlaced(orderId: String) extends OrderResult
case class OrderCancelled(orderId: String) extends OrderResult
case class OrderFailed(reason: String) extends OrderResult
def orderProcessor: Behavior[OrderCommand] =
Behaviors.setup { ctx =>
Behaviors.receiveMessage {
case PlaceOrder(item, qty, replyTo) =>
val orderId = java.util.UUID.randomUUID().toString
ctx.log.info(s"Processing order $orderId: $qty x $item")
replyTo ! OrderPlaced(orderId)
Behaviors.same
case CancelOrder(orderId, replyTo) =>
replyTo ! OrderCancelled(orderId)
Behaviors.same
}
}
|
Financial Systems
Scala dominates certain areas of financial technology — particularly high-frequency trading, risk calculation engines, and event sourcing:
- Goldman Sachs uses Scala extensively for derivatives pricing
- Barclays built significant infrastructure in Scala
- Morgan Stanley has large Scala codebases
- Two Sigma builds quant research infrastructure in Scala
The appeal is clear: you need a language that’s fast (JVM JIT compilation), can express complex domain logic (algebraic types, type classes), has strong concurrency support (Akka, ZIO), and compiles to bytecode that can use the mature Java financial library ecosystem.
Microservices with http4s
http4s is the functional HTTP library for Scala, built on cats-effect and fs2 streams:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
|
import cats.effect._
import org.http4s._
import org.http4s.dsl.io._
import org.http4s.ember.server._
import org.http4s.circe.CirceEntityCodec._
import io.circe.generic.auto._
import com.comcast.ip4s._
case class User(id: Long, name: String, email: String)
case class CreateUserRequest(name: String, email: String)
def routes(userService: UserService[IO]): HttpRoutes[IO] = {
val dsl = new Http4sDsl[IO] {}
import dsl._
HttpRoutes.of[IO] {
case GET -> Root / "users" / LongVar(id) =>
userService.findById(id).flatMap {
case Some(user) => Ok(user)
case None => NotFound()
}
case req @ POST -> Root / "users" =>
for
body <- req.as[CreateUserRequest]
user <- userService.create(body.name, body.email)
resp <- Created(user)
yield resp
case DELETE -> Root / "users" / LongVar(id) =>
userService.delete(id) *> NoContent()
}
}
object Server extends IOApp.Simple:
def run: IO[Unit] =
EmberServerBuilder
.default[IO]
.withHost(ipv4"0.0.0.0")
.withPort(port"8080")
.withHttpApp(routes(???).orNotFound)
.build
.use(_ => IO.never)
|
The http4s approach — pure functions, IO everywhere, streaming by default — means your HTTP server is fully testable without starting a real server, handles backpressure automatically via fs2 streams, and composes cleanly with any other cats-effect code.
Honest Assessment
Compile Times
This is Scala’s most persistent criticism, and it’s valid. A medium-sized Scala 2 project with Shapeless and Circe derivations could take 3-5 minutes for a clean compile. That’s not a typo.
Scala 3 improved this significantly. The new type system is more efficient, the derives mechanism is faster than the Shapeless-based derivation it replaces, and the compiler team has made continuous improvements. A typical Scala 3 service compiles a full clean build in 30-90 seconds — still slower than Go or Kotlin, but no longer productivity-destroying.
Incremental compilation (which sbt, Mill, and the Scala compiler server handle) means day-to-day compile times are much better: changing one file typically recompiles in 3-10 seconds.
Practical mitigation strategies:
- Use
sbt ~compile or sbt ~test for watch mode
- Structure your project into modules — only the changed module and its dependents need recompilation
- Use
bloop as a compilation server for better IDE and CLI integration
- Profile your build with
-Ytime to find expensive type derivations
The Learning Curve
Scala has one of the steepest learning curves of any mainstream language. The problem isn’t any single feature — it’s the interaction between features.
You can learn pattern matching in a day. You can learn case classes and sealed traits in a day. Understanding variance takes a week. Higher-kinded types and type class derivation take months of practice. The monad transformer stack (OptionT, EitherT, StateT combined) has confused senior engineers who came from Haskell.
Scala 3 genuinely helps here. The explicit given/using syntax makes implicit resolution visible. Better error messages in the compiler make type errors more diagnosable. The derives keyword eliminates many of the most confusing Shapeless patterns.
The honest advice: budget 3-6 months before you’re productive writing idiomatic Scala with Cats or ZIO. Budget another 6 months before you’re comfortable with the type system’s advanced features. That’s not a criticism — it’s calibration.
Scala 2 vs Scala 3 Migration Pain
If you have a large Scala 2 codebase, migration to Scala 3 is not trivial. The blockers:
-
Macro rewriting: Scala 2 macros are completely incompatible with Scala 3. Every library using Scala 2 macros had to rewrite them. Most major libraries (Circe, Cats, Doobie, Akka under the Pekko fork) have done this, but some smaller libraries haven’t.
-
scala.reflect removal: Scala 2’s runtime reflection API is gone in Scala 3. Code using TypeTag and WeakTypeTag needs rewriting.
-
Shapeless: Shapeless 2 is Scala 2 only. Shapeless 3 is a near-complete rewrite with different APIs. Most code that used Shapeless directly (rather than through a library) needs to be migrated.
-
Implicit code: The implicit keyword still works in Scala 3 for backward compatibility, but implicit conversions require explicit opt-in (import scala.language.implicitConversions). Code that relied heavily on implicit conversions needs auditing.
The migration path is well-documented, and there’s a tool (scala-migrate) that automates some of it. For greenfield projects, use Scala 3. For existing large codebases, plan a migration carefully or consider staying on Scala 2.13 until your dependency graph is fully ported.
The Scala community has two distinct camps:
The pragmatist camp prefers Scala for its expressive syntax and JVM interop, uses Scala like “better Java”, employs case classes and pattern matching extensively but avoids monad transformers, and tends to use Play Framework or Akka for HTTP services. Airbnb, Twitter (historically), and many large enterprises fall here.
The FP purist camp embraces Cats, ZIO, and the full type class hierarchy. Every function is pure, effects are captured in IO, and the codebase looks more like Haskell than Java. Smaller companies, consultancies like 47 Degrees, and library authors tend to be here.
These camps have genuinely different opinions about what Scala is for, which creates a fragmented ecosystem with competing libraries solving the same problems differently. This is a real cost when hiring or joining a Scala team.
The Akka licensing change (2022) — when Lightbend relicensed Akka to a BSL license — was a significant community event. The Apache Foundation forked Akka as Apache Pekko, and the community largely moved to Pekko for open-source projects. This illustrates the fragility of depending on proprietary commercial backing for infrastructure.
When Scala Is the Right Choice
Strong fit:
- Data engineering and analytics (Spark)
- High-throughput event processing (Kafka ecosystem)
- Systems where you need complex domain modeling (financial services, logistics)
- Teams that value compile-time correctness over iteration speed
- Greenfield services where you control the full stack and can build around cats-effect or ZIO
- Data-intensive applications where the functional streaming model (fs2/ZIO Streams) is a natural fit
Not a strong fit:
- Teams without functional programming background who need to ship fast
- Microservices that are CRUD with minimal domain logic — Go or Kotlin is faster to iterate
- Latency-sensitive systems where GC pauses are unacceptable (consider GraalVM native image compilation, or look at Rust)
- Teams that change frequently — onboarding a new engineer to a Cats-heavy codebase takes time
- Simple tooling/automation scripts — Python, Go, or even scala-cli for quick tasks
Compared to the Alternatives
vs. Kotlin: Kotlin has lighter FP capabilities but much better IDE support, faster compilation, and easier onboarding. If you want JVM FP without Scala’s complexity costs, Arrow (Kotlin’s FP library) is a viable option. Kotlin is the clear choice for Android. For backend services without heavy FP requirements, Kotlin is often better.
vs. Haskell: Haskell is purer, more mathematically consistent, and has a more expressive type system (type classes with functional dependencies, linear types). But the JVM ecosystem is incomparably larger, and interfacing with Java libraries from Scala is trivial. Haskell is better for languages research and systems where you need the type system to prove program properties. Scala is better for production services where you need to call a Java library.
vs. Go: Go is roughly 10x faster to compile, dramatically simpler to learn, and has excellent tooling for network services and devops tooling. If your problem is “build a reliable HTTP service or CLI tool”, Go is almost always faster to ship with. Scala’s advantage is expressiveness and the type system — for complex domain logic, the ADT + type class pattern catches entire categories of bugs that you’d have to write tests for in Go.
vs. Rust: Not competing for the same use cases. Rust doesn’t have a GC, runs on bare metal, and is appropriate for systems programming. Scala is a JVM language. The only overlap is in performance-critical services, and even there the engineering investment in Rust is much higher. GraalVM native image compilation can make Scala competitive on startup time and memory footprint.
A Practical Starting Point
If you’re evaluating Scala for a project, here’s a realistic onboarding path:
Week 1-2: Scala basics, case classes, pattern matching, collections. Read “Programming in Scala” by Odersky et al. or work through the Scala 3 Tour at docs.scala-lang.org.
Week 3-4: The type system. Variance, type classes with given/using. Read through the Cats documentation for Functor, Applicative, Monad — focus on the intuition, not the math.
Month 2: Pick either cats-effect or ZIO. Don’t try to learn both simultaneously. Build a small service end to end — HTTP routes, database access, some background task. Use http4s + doobie + cats-effect OR ZIO HTTP + ZIO JDBC + ZIO.
Month 3+: Deeper type system features — higher-kinded types, type class derivation with derives. Learn the opaque type pattern for domain modeling. Read “Functional Programming in Scala” (the Red Book) for the theoretical background.
For a team adoption:
- Start with a new service, not a migration
- Establish code style standards early (use Scalafmt, Scalafix)
- Choose your effect system before writing production code — mixing cats-effect and ZIO is possible but painful
- Invest in compile time from the start: structure as modules, use bloop
- Accept that onboarding will take 3-6 months per engineer
Summary
Scala 3 is a genuinely excellent language. The type system is expressive enough to encode domain invariants that would require extensive tests in other languages. The functional programming ecosystem (Cats, ZIO) provides abstractions that eliminate whole categories of bugs — race conditions managed by STM, resource leaks prevented by Resource, partial function bugs caught by exhaustive matching. The JVM gives you access to the largest library ecosystem on the planet.
The costs are real: compile times, learning curve, and a community that hasn’t fully converged on a single way to build applications. You’re also betting that the ecosystem continues to evolve — the Akka situation showed that dependencies on commercial backing can turn.
For the right team and the right problem, Scala 3 + ZIO or cats-effect is one of the most powerful combinations in backend engineering. For a team without functional programming experience building a simple CRUD service, you’re taking on more complexity than the problem warrants.
The language demands respect. It will reward you with the ability to build systems whose correctness you can reason about at a level that most languages don’t allow. Whether that tradeoff makes sense depends entirely on what you’re building and who you’re building it with.
Further Reading
- docs.scala-lang.org/scala3 — The official Scala 3 tour and reference, significantly improved
- typelevel.org — Home of Cats, cats-effect, http4s, doobie, and the broader typelevel stack
- zio.dev — ZIO documentation, with excellent getting started guides
- “Programming in Scala, 5th Edition” — Odersky, Spoon, Venners — the language reference
- “Functional Programming in Scala, 2nd Edition” — Pilquist, Chiusano, Bjarnasson — the Red Book, now updated for Scala 3
- “Essential Effects” by Adam Rosien — practical cats-effect guide
- Scala Times (scalatimes.com) — weekly newsletter
- Scalac Blog (scalac.io/blog) — practical posts from a Scala consultancy
- Li Haoyi’s blog (lihaoyi.com) — pragmatic Scala from the creator of Mill, Ammonite, and many essential libraries
+++
Comments