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

Scala Roadmap

  1. Fundamentalsval/var, type inference, control flow, methods
  2. Collections + FPList/Map, map/filter/fold, for-yield comprehensions
  3. ADTs + Pattern Matchingcase class, sealed, Option/Either/Try
  4. Traits + Type Systemgiven/using, variance, opaque/union types, type classes
  5. Effects + EcosystemIO/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

Scala FP Core

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 defaultList, 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

Scala Features

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

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

  1. Use Scala 3 (@main, given/using, extension, opaque, | union types) — it’s the present, not the future.
  2. val by default; everything immutable by default.
  3. case class for data, sealed trait for variants — ADTs are the modeling tool.
  4. Pattern match exhaustively on sealed types — let the compiler check.
  5. Option/Either/Try over null/throw — chain with for-comprehensions.
  6. Type classes via given/using for ad-hoc polymorphism; extension for methods.
  7. IO/ZIO for effects in production FP; Future for simple async.
  8. scala-cli to start, sbt for real projects; Metals or IntelliJ for IDE.
  9. munit/ScalaTest for testing; run in CI.
  10. Spark for big data — Scala’s strongest niche.

Common Pitfalls

  • var mutation in concurrent code — breaks referential transparency; prefer val + immutable collections.
  • Non-exhaustive match — compiler warns; missing cases throw MatchError at runtime. Fix the cases, don’t add case _: Throwable.
  • null from Java interop — Scala’s Option doesn’t protect you from Java nulls. Wrap with Option(nullable) to convert.
  • Future is eager — it starts running immediately, unlike IO. Don’t use Future if you need to compose/describe before running.
  • Blocking on FutureAwait.result blocks 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 var in a +T class fails; that’s the compiler catching a soundness bug, not a nuisance.
  • == on collections — works (calls equals), but be careful with order for List vs Set.
  • 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 syntaximplicit (Scala 2) vs given/using (Scala 3); pick one and stick with it.

What to Learn Next

  • Scala docsdocs.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 docszio.dev for the ZIO effect system.
  • The Type Astronaut’s Guide to Shapeless — advanced generic programming (Scala 2 era but still instructive).
  • Rock the JVMrockthejvm.com courses and blog.
  • Spark docsspark.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:

Watch PyShine on YouTube

Contents