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, match and try all produce values).

  • Immutable data is cheap to write: val, case classes with copy, immutable collections, enums and sealed hierarchies (see Case Classes).

  • Functions are first-class values, so behaviour can be passed around (see Functions as Values).

  • The compiler checks exhaustivity of match over 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

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)

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

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.

Examples from the standard library:

Pure Impure

List.map, filter, foldLeft, sum, String.toUpperCase, Math.max

println, scala.io.StdIn.readLine, System.currentTimeMillis, scala.util.Random, Thread.sleep

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

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+) and List.of are 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. val and var work 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 if conditions with parentheses and braces; the then and braceless syntax of Scala 3 does not change the semantics.

Pitfalls

  • A val holding a mutable object is not immutable. Use immutable types in fields.

  • Hidden effects. println in the middle of a "pure" function, a Random or time call, or a throw, break the guarantees. Search for Unit results and for calls into System, Random and java.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 as total above) is still pure from the outside. Do not treat every var as 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 b and braces around every block; Scala 3 uses if cond then a else b and optional braces.

  • They describe var and while as "Java-style" and avoid them entirely; Scala 3 code still uses a local var where it is clearer, as in the pitfalls above.

  • They present a Product with Serializable or trait-based ADT; Scala 3 uses enum for 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.