Learn Scala in a Single Post: A Complete Scala Tutorial from Case Classes and Traits to Type Classes and Effect Systems
Scala fuses object-oriented and functional programming on the JVM — you get classes/traits and first-class functions, immutability, algebraic data types, and one of the most powerful type systems in mainstream use. It’s the language behind Apache Spark, the Akka/Pekko actor systems, and a generation of effect-system libraries (cats-effect, ZIO) that bring pure FP to production.
This post teaches the whole language in five stages with runnable snippets. By the end you’ll understand case classes and pattern matching, the Option/Either/Try trinity, type classes via given/using, variance, and the effect-system approach to side effects.
We target Scala 3.x (the redesigned syntax: given/using/extension/opaque replace Scala 2’s implicit machinery). Everything here compiles on a current toolchain.
The Roadmap
- Fundamentals —
val/var, type inference, control flow, methods - Collections + FP —
List/Map,map/filter/fold, for-yield comprehensions - ADTs + Pattern Matching —
case class,sealed,Option/Either/Try - Traits + Type System —
given/using, variance, opaque/union types, type classes - Effects + Ecosystem —
IO/cats-effect, ZIO, Akka/Pekko, sbt, ScalaTest, Spark
Stage 1 — Fundamentals
A program
@main def hello(): Unit = println("Hello, Scala!")
Scala 3 uses the @main annotation for entry points (Scala 2 used object X extends App). Run it:
scala-cli run . # scala-cli: fast, no build setup
scala-cli Hello.scala # run a single file
sbt run # sbt project
val vs var and type inference
val n = 10 // val = immutable reference — DEFAULT
var x = 5 // var = mutable
// n = 20 // error — val is immutable
val pi: Double = 3.14 // explicit type annotation
val name = "Ada" // inferred as String
val xs: List[Int] = List(1, 2, 3)
val by default, var only when you must mutate. Scala is expression-oriented: nearly everything returns a value.
Basic types and strings
val n: Int = 10
val d: Double = 3.14
val b: Boolean = true
val c: Char = 'A'
val s: String = "Hello"
val greeting = s"Hi, $name! ${1 + 2}" // s-interpolator
val raw = raw"C:\Users\ada\not_escape" // raw interpolator
val f = f"$name%10s costs $pi%.2f" // f-interpolator (printf-style)
s.length; s.toUpperCase; s.startsWith("H")
s.split(",").toList // Scala strings -> Scala collections
Scala has three string interpolators: s"..." (substitution), f"..." (printf formatting), raw"..." (no escapes). String is Java’s String but with extension methods that bridge to Scala collections.
Control flow — everything is an expression
val label = if (x > 0) "pos" else "neg" // if returns a value
val dayLabel = day match {
case "MON" | "TUE" => "weekday" // alternation
case "SAT" | "SUN" => "weekend"
case _ => "other" // _ = wildcard/default
}
for (i <- 0 until 5) println(i) // 0..4 (until = exclusive)
for (i <- 0 to 5) println(i) // 0..5 (to = inclusive)
for (n <- nums if n > 0) println(n) // with filter
while (cond) { }
if and match are expressions — they return values, so you assign the result directly. match is Scala’s pattern matching (below); it’s far more powerful than a switch.
Methods
def add(a: Int, b: Int): Int = a + b // expression body
def greet(name: String, greeting: String = "Hi"): String =
s"$greeting, $name!" // default args
greet("Ada") // Hi, Ada!
greet("Ada", greeting = "Hey") // named arg
def sum(nums: Int*): Int = nums.sum // varargs
sum(1, 2, 3) // 6
sum(Seq(1, 2, 3)*) // spread a sequence
// Currying / multiple param lists
def curry(a: Int)(b: Int): Int = a + b
curry(1)(2) // 3
// By-name param — lazy evaluation
def log(msg: => String): Unit = if (debug) println(msg) // msg evaluated only if debug
Scala methods can have multiple parameter lists (currying) and by-name parameters (: => T — evaluated on each use, lazily). By-name params are how Scala builds custom control structures (while, repeat, logging macros).
Stage 2 — Collections and Functional Programming
Immutable collections
val list = List(1, 2, 3) // List[Int] — immutable, linked list
val vec = Vector(1, 2, 3) // Vector[Int] — immutable, indexed (fast random access)
val map = Map("a" -> 1, "b" -> 2)
val set = Set(1, 2, 3)
list(0); list.head; list.tail; list.size
list :+ 4 // append -> List(1,2,3,4)
0 +: list // prepend
list.updated(0, 9) // new list with index 0 = 9
map + ("c" -> 3) // add
map("a") // 1 (throws if missing)
map.getOrElse("missing", 0) // 0
Scala collections are immutable by default — List, Vector, Map, Set return new copies on modification. The original is never mutated. Vector is the general-purpose choice (effective random access); List is a linked list (good for head/tail recursion). Mutable versions exist (mutable.ListBuffer, mutable.Map) but use them sparingly.
Higher-order functions
val nums = List(1, 2, 3, 4, 5)
nums.map(_ * 2) // List(2, 4, 6, 8, 10) — _ = the param
nums.filter(_ > 2) // List(3, 4, 5)
nums.filter(_ > 2).map(_ * 2) // chained pipelines
nums.reduce(_ + _) // 15
nums.fold(0)(_ + _) // 15 (with initial)
nums.groupBy(_ % 2) // Map(0 -> List(2,4), 1 -> List(1,3,5))
nums.sorted
nums.sortBy(-_) // descending
nums.distinct
nums.flatMap(n => List(n, n * 10)) // List(1,10,2,20,...) — flatten after map
// Functions as values
val sq: Int => Int = x => x * x // function type Int => Int
val sq2: Int => Int = _ * 2
nums.map(sq)
_ is the placeholder for “the parameter” — map(_ * 2) is shorthand for map(x => x * 2). flatMap is the backbone of for-comprehensions (below) and monadic chaining.
For-yield comprehensions
val pairs = for {
x <- List(1, 2, 3)
y <- List('a', 'b')
if x % 2 == 0
} yield (x, y)
// List((2,'a'), (2,'b'))
// Equivalent to flatMap/map/filter:
List(1, 2, 3).filter(_ % 2 == 0).flatMap(x => List('a', 'b').map(y => (x, y)))
For-comprehensions are syntactic sugar over flatMap/map/filter/withFilter — they work on any monad (List, Option, Either, Future, IO). This single construct handles iteration, filtering, and monadic chaining with the same readable syntax.
Immutability and pure functions
// Pure function: same input -> same output, no side effects
def add(a: Int, b: Int): Int = a + b // pure
// Impure (side effect):
def addAndLog(a: Int, b: Int): Int = {
println(s"adding $a + $b") // side effect
a + b
}
// Prefer pure: test easily, parallelize, reason about
Pure functions (no side effects, same input → same output) are the FP ideal. They’re trivially testable, parallelizable, and cacheable. Scala lets you write pure functions and pushes side effects to the edges (via IO/effect systems, below).
Stage 3 — ADTs and Pattern Matching
Case classes — ADT products
case class Point(x: Double, y: Double) // auto eq/hash/copy/toString + companion
val p = Point(1, 2) // no `new` (companion apply)
val q = p.copy(y = 5) // non-destructive copy
p == Point(1, 2) // true — value equality
val (x, y) = p // destructuring (componentN)
println(p) // "Point(1.0,2.0)"
case class is the Scala workhorse for data — it generates equals/hashCode/toString/copy, a companion with an apply (so no new), and unapply (for pattern matching). Use case classes, not plain classes, for data.
Sealed + pattern matching — ADT sums
sealed trait Shape
object Shape:
case class Circle(r: Double) extends Shape
case class Square(side: Double) extends Shape
case class Triangle(b: Double, h: Double) extends Shape
def area(s: Shape): Double = s match
case Circle(r) => math.Pi * r * r // extracts r
case Square(side) => side * side
case Triangle(b, h) => 0.5 * b * h
// no default needed — sealed => exhaustive check
Sealed hierarchies + case classes = algebraic data types (sum types with product variants). The compiler knows all subtypes (must be in the same file), so match is exhaustive-checked — miss a case and you get a warning/error. This is how Scala models domains: sealed trait OrderState; case object Pending extends OrderState; case class Shipped(tracking: String) extends OrderState; case object Delivered extends OrderState.
Option, Either, Try — no null, no throws
// Option — may be absent
def find(id: Int): Option[String] = ...
find(1) match
case Some(name) => println(name)
case None => println("not found")
val upper: Option[String] = find(1).map(_.toUpperCase)
val name: String = find(1).getOrElse("default")
find(1).orElse(find(2)) // first Some, else second
// Either — success or typed error
def parse(s: String): Either[String, Int] =
if (s.forall(_.isDigit)) Right(s.toInt) else Left(s"bad input: $s")
parse("42") match
case Right(n) => println(n)
case Left(err) => println(err)
// Try — exception as value
import scala.util.{Try, Success, Failure}
val r: Try[Int] = Try("42".toInt)
r match
case Success(n) => println(n)
case Failure(e) => println(e)
Scala replaces null with Option[T] (Some/None) and exceptions with Either[L, R] (Right/Left) or Try[T] (Success/Failure). All three are monads — they chain via for-comprehensions:
for
n <- parse("42")
doubled <- parse(n.toString).map(_ * 2)
yield doubled // Right(84) or Left(err)
If any step is None/Left/Failure, the whole comprehension short-circuits to that value. This is the FP alternative to try/catch — typed, composable, no hidden control flow.
Stage 4 — Traits and the Type System
Traits
trait Greetable:
def name: String
def greet: String = s"Hi, $name" // default implementation
trait Comparable[T]:
def compareTo(other: T): Int
class Person(val name: String) extends Greetable, Comparable[Person]:
override def compareTo(other: Person): Int = name.compareTo(other.name)
val p = Person("Ada")
p.greet // "Hi, Ada"
Scala classes can extend multiple traits (single class inheritance, multiple trait mixins). Traits can have default implementations and state. They’re Scala’s interfaces + mixins combined.
Type classes via given/using
// A type class: a trait parameterized by a type
trait Show[T]:
def show(t: T): String
// Instances (given) for specific types
given Show[Int] with
def show(t: Int): String = t.toString
given Show[String] with
def show(t: String): String = s""""$t""""
// Use via 'using' (contextual parameter)
def printAll[T](xs: List[T])(using s: Show[T]): Unit =
xs.foreach(x => println(s.show(x)))
printAll(List(1, 2, 3)) // uses the Show[Int] given automatically
// Extension methods — add to existing types
extension (s: String) def shout: String = s.toUpperCase + "!"
"hello".shout // "HELLO!"
Type classes (trait Show[T] + given Show[Int]) are ad-hoc polymorphism: you can add behavior to any type (including ones you don’t own) without modifying it. This is Scala’s signature power over Java/Kotlin interfaces — you can retroactively make Int “showable”. Scala 3’s given/using/extension syntax replaced Scala 2’s implicit keywords, which were the same mechanism with worse ergonomics.
Variance
class Box[+T](val value: T) // +T covariant: Box[Dog] <: Box[Animal]
class Sink[-T] { def consume(t: T): Unit = () } // -T contravariant: Sink[Animal] <: Sink[Dog]
class Cell[T](var v: T) // invariant: mutable state forces invariance
// Function types: Function1[-A, +B] — contravariant in arg, covariant in return
Variance annotations (+T covariant, -T contravariant) tell the compiler how subtypes propagate through generics. The rule: producers are covariant (+), consumers are contravariant (-), mutable is invariant. Functions are Function1[-A, +B] — contravariant in their input, covariant in their output.
Opaque types and union types
// Opaque type — zero-cost newtype (Scala 3)
object Ids:
opaque type UserId = Long
object UserId:
def apply(l: Long): UserId = l
extension (u: UserId) def value: Long = u
import Ids.*
val u: UserId = UserId(42) // compile-time distinct, runtime Long
// Union types (Scala 3)
type IntOrString = Int | String
def f(x: IntOrString): String = x match
case n: Int => s"int $n"
case s: String => s"string $s"
// Intersection types
type A = { def foo: Int }
type B = { def bar: Int }
type AB = A & B // has both foo and bar
Opaque types create zero-cost distinct types (newtypes) — UserId is Long at runtime but distinct at compile time, preventing you from passing a Long where a UserId is expected. Union types (A | B) are ad-hoc sum types without a sealed hierarchy.
Stage 5 — Effects and the Ecosystem
The problem: side effects in pure code
FP wants pure functions, but real programs do I/O (read files, call APIs, mutate state). The Scala solution: effect types that describe side effects as values, then run them at the edge of your program.
IO (cats-effect)
import cats.effect.{IO, IOApp}
object Main extends IOApp.Simple:
def run: IO[Unit] =
for
_ <- IO.println("What's your name?")
name <- IO.readLine
_ <- IO.println(s"Hi, $name!")
yield ()
// IO is a value describing the effect — pure until run
val fetch: IO[String] = IO("data") // no side effect yet
val upper: IO[String] = fetch.map(_.toUpperCase)
val both: IO[Unit] = fetch.flatMap(a => upper.flatMap(b => IO.println(s"$a $b")))
IO[A] is a description of a computation that produces A and may have effects — it’s a pure value until you run it (via IOApp or unsafeRunSync). For-comprehensions compose IOs. This makes async, cancellation, and resource safety composable and pure. cats-effect is the standard; ZIO is the alternative with a similar model and stronger ergonomics.
// ZIO
import zio.*
object Main extends ZIOAppDefault:
def run: ZIO[Any, Nothing, Unit] =
for
_ <- Console.printLine("name?")
name <- Console.readLine
_ <- Console.printLine(s"Hi, $name!")
yield ()
Future (for when you don’t want an effect system)
import scala.concurrent.{Future, ExecutionContext}
import scala.concurrent.Future.*
given ExecutionContext = ExecutionContext.global
val f: Future[Int] = Future { 1 + 1 }
val r: Future[Int] = f.map(_ * 2)
val all: Future[List[Int]] = traverse(List(1, 2, 3))(n => Future(n * 2))
Future is the JVM-async primitive (like a Promise) — it executes eagerly on an ExecutionContext. Simpler than IO but less pure (starts running immediately, not a description). Use it for simple async; prefer IO/ZIO for production FP.
Akka / Pekko — actors
// Pekko (the Akka fork, post-license-change)
import org.apache.pekko.actor.*
object Greeter:
case class Greet(name: String)
class Greeter extends Actor:
def receive: Receive =
case Greeter.Greet(name) => println(s"Hi, $name")
The actor model: isolated actors communicate by message passing, no shared state. Used for distributed/concurrent systems (Pekko is the Apache fork of Akka after its 2022 license change).
Apache Spark — Scala’s killer app
import org.apache.spark.sql.SparkSession
val spark = SparkSession.builder.appName("wc").master("local").getOrCreate()
import spark.implicits.*
val counts = spark.read.text("input.txt")
.select(explode(split($"value", " ")).as("word"))
.groupBy("word").count()
counts.show()
Spark is written in Scala and its DataFrame/Dataset APIs are most ergonomic from Scala. Scala’s place in big data is one of its strongest niches.
The Toolchain
scala-cli (fast, single-file) and sbt (full projects)
# scala-cli — fastest way to start
scala-cli run .
scala-cli test .
scala-cli package . --library # build a jar
# sbt — full projects
sbt new scala/scala3-seed.g8 # scaffold
sbt run
sbt test
sbt console # REPL
sbt compile; sbt package
A minimal build.sbt:
ThisBuild / scalaVersion := "3.4.2"
ThisBuild / organization := "com.example"
lazy val root = (project in file("."))
.settings(
name := "myapp",
libraryDependencies ++= Seq(
"org.typelevel" %% "cats-effect" % "3.5.0",
"org.scalameta" %% "munit" % "1.0.0" % Test
)
)
Testing
// munit
class MyTest extends munit.FunSuite:
test("addition"):
assertEquals(1 + 1, 2)
// ScalaTest (BDD style)
class Spec extends AnyFunSuite:
test("addition") { assert(1 + 1 == 2) }
Tooling
- scala-cli — single-file / small project runner; no build setup.
- sbt — the standard build tool for larger projects.
- Metals — LSP server for Scala (VS Code, etc.); IntelliJ has its own support.
- ScalaTest / munit / weaver — testing frameworks.
- cats / cats-effect — FP libraries + effect system.
- ZIO — alternative effect system with its own ecosystem.
- Akka / Pekko — actors and streams.
- http4s / Tapir — type-safe HTTP.
- Apache Spark — big data.
- Scala.js / Scala Native — compile to JS / LLVM instead of JVM.
A Quick-Start Checklist
- Use Scala 3 (
@main,given/using,extension,opaque,|union types) — it’s the present, not the future. valby default; everything immutable by default.case classfor data,sealed traitfor variants — ADTs are the modeling tool.- Pattern match exhaustively on sealed types — let the compiler check.
Option/Either/Tryover null/throw — chain with for-comprehensions.- Type classes via
given/usingfor ad-hoc polymorphism;extensionfor methods. IO/ZIOfor effects in production FP;Futurefor simple async.- scala-cli to start, sbt for real projects; Metals or IntelliJ for IDE.
- munit/ScalaTest for testing; run in CI.
- Spark for big data — Scala’s strongest niche.
Common Pitfalls
varmutation in concurrent code — breaks referential transparency; preferval+ immutable collections.- Non-exhaustive match — compiler warns; missing cases throw
MatchErrorat runtime. Fix the cases, don’t addcase _: Throwable. nullfrom Java interop — Scala’sOptiondoesn’t protect you from Javanulls. Wrap withOption(nullable)to convert.Futureis eager — it starts running immediately, unlikeIO. Don’t useFutureif you need to compose/describe before running.- Blocking on
Future—Await.resultblocks a thread; use async combinators (map/flatMap/for). - Implicit/given ambiguity — two
given Show[Int]in scope → compile error. Keep instances in companion objects or named. - Variance errors — mutable
varin a+Tclass fails; that’s the compiler catching a soundness bug, not a nuisance. ==on collections — works (callsequals), but be careful with order forListvsSet.- For-comprehension on the wrong type — works on
List,Option,Future,IO(any monad), but the semantics differ (List = nested loops, Option = short-circuit). - Scala 2 vs Scala 3 syntax —
implicit(Scala 2) vsgiven/using(Scala 3); pick one and stick with it.
What to Learn Next
- Scala docs — docs.scala-lang.org the official tour + book + reference.
- Functional Programming in Scala (“the red book”) by Paul Chiusano & Rúnar Bjarnason — the canonical FP-in-Scala text; builds an effect system from scratch.
- Programming in Scala by Odersky et al. — the comprehensive language reference by its designer.
- Scala with Cats by Noel Welsh — type classes and FP with the cats library (free online).
- Practical FP in Scala / ZIO docs — zio.dev for the ZIO effect system.
- The Type Astronaut’s Guide to Shapeless — advanced generic programming (Scala 2 era but still instructive).
- Rock the JVM — rockthejvm.com courses and blog.
- Spark docs — spark.apache.org/docs for big data.
Scala’s learning curve is real — it’s a big language with a powerful type system — but the payoff is unmatched expressiveness: ADTs, type classes, effect systems, and a type system that catches whole classes of bugs at compile time. Start with case classes and pattern matching; the type-class and effect-system machinery comes later and is worth it.
Good luck — and reach for case class first.
Resources:
- Scala: https://www.scala-lang.org/
- Docs: https://docs.scala-lang.org/
- scala-cli: https://scala-cli.virtuslab.org/
- cats-effect: https://typelevel.org/cats-effect/
- ZIO: https://zio.dev/
- Spark: https://spark.apache.org/
Enjoyed this post? Never miss out on future posts by following us