Functional Programming Principles
|
This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production. The reference material consulted while preparing these pages is listed in the section bibliography. |
This page is written against Scala 3.10 (3.10.0, released 2026-10-12) and every example was compiled and run with Scala 3.10.0 through Scala CLI on JDK 26 (checked on 2026-10-15). Nothing on this page depends on a feature newer than Scala 3.0, so it applies unchanged to the 3.9 LTS (3.9.0, 2026-09-03) and to the 3.3 LTS line (3.3.8).
Scala is a hybrid language: it has var, mutable collections and while loops, and it also makes it very easy to program
without them. Functional programming (FP) is the second style. It builds programs by combining functions that take values and
return values, over data that never changes. This page covers the four ideas that everything else in the functional section
builds on: immutability, pure functions, referential transparency and writing code as expressions. It uses the
trailhead scenario, a toolkit that plans hikes on a network of mountain trails.
The mechanics of functions as values are in Functions as Values and
Higher-Order Functions & Closures; the details of val and
var are in Values, Variables & Literals.
Why functional programming in Scala
FP is a discipline, not a language feature. Scala does not force it, but it removes most of the friction:
-
Everything is an expression, so a value can be built by composition (
if,matchandtryall produce values). -
Immutable data is cheap to write:
val, case classes withcopy, immutable collections, enums andsealedhierarchies (see Case Classes). -
Functions are first-class values, so behaviour can be passed around (see Functions as Values).
-
The compiler checks exhaustivity of
matchover sealed types, which makes data and the functions over it a closed, verifiable pair (see Pattern Matching).
The benefits claimed for the style are concrete. A function that depends only on its arguments is easy to test, because no
set-up or global state is needed. Immutable values can be shared between threads without locks. Code reads as a description of
what the result is, not of the steps that mutate things to reach it. The cost is a different way of thinking: loops become
map, filter and folds, and updates become new values.
Immutability
A val cannot be reassigned. A var can. The functional style uses val almost everywhere, and the compiler enforces it:
//> using scala 3.10.0
case class Trail(name: String, lengthKm: Double)
@main def run(): Unit =
val pico = Trail("Pico", 12.5)
pico.lengthKm = 14.0
val total = 0
total = total + 1
-- [E052] Type Error: bad.scala:5:16
5 | pico.lengthKm = 14.0
| ^^^^^^^^^^^^^^^^^^^^
| Reassignment to val lengthKm
|
| longer explanation available when compiling with `-explain`
-- [E052] Type Error: bad.scala:7:8
7 | total = total + 1
| ^^^^^^^^^^^^^^^^^
| Reassignment to val total
|
| longer explanation available when compiling with `-explain`
2 errors found
Compilation failed
Source: Scala 3 Book: Immutable Values
The parameters of a case class are val`s, so a case class instance is immutable. A "change" is a new value built with `copy,
which keeps every field not named. The standard immutable collections work the same way: operations return a new collection and
leave the old one untouched.
//> using scala 3.10.0
case class Trail(name: String, lengthKm: Double, ascentM: Int)
@main def run(): Unit =
val pico = Trail("Pico", 12.5, 900)
val longer = pico.copy(lengthKm = 14.0)
println(pico)
println(longer)
val names = List("Pico", "Lago")
val more = "Cumbre" :: names
println(names)
println(more)
println(more.tail eq names)
val m = Map("Pico" -> 12.5)
println(m.updated("Lago", 4.0))
println(m)
Trail(Pico,12.5,900)
Trail(Pico,14.0,900)
List(Pico, Lago)
List(Cumbre, Pico, Lago)
true
Map(Pico -> 12.5, Lago -> 4.0)
Map(Pico -> 12.5)
Source: Scala 3 Book: Immutable Values
The expression more.tail eq names is true: the new list reuses the old one as its tail instead of copying it. This is
structural sharing. Persistent data structures (List, Vector, Map, Set) are built so that most of a "modified" copy is
shared with the original, which is why returning a new collection is not as expensive as it sounds. The cost model of each
collection is covered in Collection Performance & Equality.
Immutability is shallow. A val that holds a mutable object (a scala.collection.mutable.ArrayBuffer, a Java ArrayList, a
class with a var field) protects the reference, not the contents. The principle applies to the data you design: make the
fields val, use immutable collections for fields, and use copy or updated for changes.
Mutable state and concurrency
Shared mutable state is the classic source of concurrency bugs. Four threads incrementing one var lose updates; four threads
that each compute a value from immutable inputs cannot interfere.
//> using scala 3.10.0
@main def run(): Unit =
// shared mutable state: four threads increment one var
var counter = 0
val threads = (1 to 4).map: _ =>
val t = Thread(() => for _ <- 1 to 100000 do counter += 1)
t.start()
t
threads.foreach(_.join())
println(s"expected 400000, correct: ${counter == 400000}")
// immutable data: each thread computes its own value, nothing is shared
val partial = (1 to 4).map(i => (1L to 100000L).sum)
println(partial.sum)
expected 400000, correct: false
20000200000
Source: Scala 3 Book: Immutable Values
The first result is a data race, so it varies from run to run and may occasionally be correct; the point is that nothing guarantees it. Concurrency tools are covered in Futures & Promises.
Pure functions and side effects
A function is pure if its result depends only on its arguments, and if calling it has no observable effect other than returning that result. A side effect is any observable effect beyond the return value:
-
Writing or reading a variable outside the function (a
var, a field, a global). -
Mutating an argument.
-
Input and output: printing, files, network, the clock, random numbers, databases.
-
Throwing an exception (the caller sees a control-flow change that is not in the return type).
The first example contrasts a function that reads and updates outer state with a pure one.
//> using scala 3.10.0
// Impure: reads and writes state outside its parameters
var visited = 0
def nextLabel(name: String): String =
visited += 1
s"$visited. $name"
// Pure: result depends only on the arguments
def label(index: Int, name: String): String = s"$index. $name"
def paceMinPerKm(minutes: Double, km: Double): Double = minutes / km
@main def run(): Unit =
println(nextLabel("Pico"))
println(nextLabel("Pico"))
println(label(1, "Pico"))
println(label(1, "Pico"))
println(paceMinPerKm(150, 12.5))
1. Pico
2. Pico
1. Pico
1. Pico
12.0
The same call nextLabel("Pico") gives two different answers, while label(1, "Pico") always gives the same one. Pure
functions are easy to test (assert on the output), to reason about (read only the body), to cache and to run in any order or in
parallel.
Source: Scala 3 Book: Pure Functions
Examples from the standard library:
| Pure | Impure |
|---|---|
|
|
Pure code is not the goal on its own: a program with no effects does nothing. The aim is to keep effects few, explicit and at the edges, as described in Functional core, imperative shell.
Referential transparency
An expression is referentially transparent if it can be replaced by its value without changing the behaviour of the program. Calls to pure functions are referentially transparent. This is what makes the substitution model work: to understand a program, replace each expression by its value, step by step, as you do in algebra.
//> using scala 3.10.0
@main def run(): Unit =
// pure: substituting the expression by its value changes nothing
val a = (1 + 2) * 3
println(a + a)
println((1 + 2) * 3 + (1 + 2) * 3)
// impure: println returns Unit, so replacing the call by its value drops the effect
val x = { println("hello"); 5 }
println(x + x)
println({ println("hello"); 5 } + { println("hello"); 5 })
// a mutable counter breaks substitution
var n = 0
def next(): Int = { n += 1; n }
val y = next()
println(y + y)
n = 0
println(next() + next())
18
18
hello
10
hello
hello
10
2
3
The first pair prints the same number whether a is a name or is inlined. In the second pair, naming the block once prints
hello once, but inlining it twice prints it twice: the block is not referentially transparent because it prints. The third pair
shows the same with state: y + y is 2, but next() + next() is 3.
A practical rule: if you may freely introduce a val for a repeated expression, or inline a val used once, the code is
referentially transparent. If doing so changes the output, an effect is hidden in the expression.
Expressions, not statements
In Scala nearly everything has a value. if / else, match, try / catch and blocks are expressions, so a result is
computed in one place and assigned once to a val. This removes most temporary `var`s that statement-oriented languages need:
//> using scala 3.10.0
enum Difficulty:
case Easy, Moderate, Hard
def classifyStatement(ascentM: Int): Difficulty =
var d = Difficulty.Easy
if ascentM > 500 then d = Difficulty.Moderate
if ascentM > 1000 then d = Difficulty.Hard
d
def classify(ascentM: Int): Difficulty =
if ascentM > 1000 then Difficulty.Hard
else if ascentM > 500 then Difficulty.Moderate
else Difficulty.Easy
def total(lengths: List[Double]): Double =
var sum = 0.0
for l <- lengths do sum += l
sum
@main def run(): Unit =
println(List(100, 700, 1500).map(classifyStatement))
println(List(100, 700, 1500).map(classify))
println(total(List(12.5, 4.0, 8.5)))
println(List(12.5, 4.0, 8.5).sum)
println(List(12.5, 4.0, 8.5).foldLeft(0.0)(_ + _))
val label = if 7 > 5 then "big" else "small"
val parsed = "42".toIntOption match
case Some(n) => n
case None => 0
val r = try "x".toInt catch case _: NumberFormatException => -1
println((label, parsed, r))
List(Easy, Moderate, Hard)
List(Easy, Moderate, Hard)
25.0
25.0
25.0
(big,42,-1)
classify and classifyStatement return the same results, but classify has no mutable variable and each branch is one
expression. total is the loop-with-accumulator form; sum and foldLeft express the same thing without a var. A for ... do loop is a statement: it has type Unit and exists only for its effect. Prefer for ... yield or collection methods
when a value is wanted (see For Comprehensions and
Conditionals & Loops).
A Unit-returning function is a strong hint of a side effect: a pure function with no result would be useless.
Functional core, imperative shell
Real programs read input, print, save and call services. The usual way to keep most of the code pure is to push the effects to the outside: a functional core of pure functions over immutable data, called from a thin imperative shell that does the I/O.
//> using scala 3.10.0
case class Trail(name: String, lengthKm: Double, ascentM: Int)
// functional core: pure functions over immutable data
def hardest(trails: List[Trail]): Option[Trail] = trails.maxByOption(_.ascentM)
def totalKm(trails: List[Trail]): Double = trails.map(_.lengthKm).sum
def render(trails: List[Trail]): String =
trails.map(t => s"${t.name}: ${t.lengthKm} km").mkString("\n")
// imperative shell: effects at the edge
@main def run(): Unit =
val trails = List(Trail("Pico", 12.5, 900), Trail("Lago", 4.0, 150), Trail("Cumbre", 8.5, 1200))
println(render(trails))
println(totalKm(trails))
println(hardest(trails))
assert(hardest(Nil).isEmpty)
assert(totalKm(trails) == 25.0)
Pico: 12.5 km
Lago: 4.0 km
Cumbre: 8.5 km
25.0
Some(Trail(Cumbre,8.5,1200))
Source: Scala 3 Book: Pure Functions
The three core functions can be tested with plain values and no mocks. Absence is returned as Option rather than null or an
exception, and failure as Either or Try; see Option & Null Safety and
Either & Try. Libraries such as Cats Effect and ZIO go further and
represent the effects themselves as values; they are listed in
Ecosystem.
Differences from other languages
-
Java. Fields are mutable unless declared
final; records (Java 16+) andList.ofare immutable, but most collections are not. Scala makes the immutable choice the shorter one (val, case class,List). See the Java page Lambdas and Method References for the Java equivalent of passing behaviour. -
Kotlin.
valandvarwork the same way; Kotlin’s collection interfaces separate read-only from mutable views, whereas Scala’s immutable collections are truly immutable and structurally shared. -
Scala 2. Identical concepts. Scala 2 spelled
ifconditions with parentheses and braces; thethenand braceless syntax of Scala 3 does not change the semantics.
Pitfalls
-
A
valholding a mutable object is not immutable. Use immutable types in fields. -
Hidden effects.
printlnin the middle of a "pure" function, aRandomor time call, or athrow, break the guarantees. Search forUnitresults and for calls intoSystem,Randomandjava.io. -
Mutable local
var`s are fine if they do not escape.A function that uses a `var accumulator internally but only depends on its arguments (such astotalabove) is still pure from the outside. Do not treat everyvaras a bug. -
Pure does not mean fast or slow. Immutable updates allocate; measure before replacing a hot loop, and keep the pure signature around a local mutable implementation if profiling asks for it.
-
Stack depth. Replacing a loop by recursion needs care; see Recursion & Tail Calls.
Where the books are outdated
-
Older Scala 2 books write
if (cond) a else band braces around every block; Scala 3 usesif cond then a else band optional braces. -
They describe
varandwhileas "Java-style" and avoid them entirely; Scala 3 code still uses a localvarwhere it is clearer, as in the pitfalls above. -
They present a
Product with Serializableortrait-based ADT; Scala 3 usesenumfor the same data (see Enums). -
Effect libraries described in older books (Scalaz, early Cats) have been replaced by Cats Effect 3 and ZIO 2 as the usual choices.