Skip to content
← All insights
Scala14 min read

Scala collections in practice

How to choose and transform immutable Scala collections, avoid accidental work, and recognise the performance and semantic differences between List, Vector, Set, Map, View and Iterator.

Choose a collection for its meaning and operations

A collection type is part of a method contract. Seq promises ordering, Set promises uniqueness and Map associates unique keys with values. A concrete type adds operational expectations: List is a linked sequence, while Vector is an indexed immutable sequence.

Start with the behaviour callers need. Return Seq when callers only need ordered traversal; return Set when duplicates would be a modelling error; return Map when lookup by a stable key is the point. Exposing a mutable implementation or an unnecessarily specific concrete type gives callers constraints they may start to depend on.

Immutable collections are the normal default in Scala. Operations such as updated, + and map return new values while safely sharing unchanged structure internally. A val can therefore refer to a collection whose implementation is efficient without permitting another part of the program to mutate it.

Let the result type state the collection contractscala 2.13
final case class AccountId(value: String)
final case class Account(id: AccountId, name: String)

def accountsInDisplayOrder: Seq[Account] = ???
def activeAccountIds: Set[AccountId] = ???
def accountsById: Map[AccountId, Account] = ???

val renamed: Map[AccountId, Account] =
  accountsById.updated(id, account.copy(name = "Ada"))

List and Vector favour different work

List provides constant-time head, tail and prepend operations. Indexing, finding its length and appending with :+ require traversal, so repeatedly appending to a List can turn a linear operation into quadratic work.

Vector offers effectively constant-time indexed access and updates while remaining immutable. It is often a better general-purpose sequence when code needs both traversal and indexed operations. Array is appropriate at a lower-level boundary where compact storage, Java interoperation or controlled mutation matters.

Small collections rarely justify elaborate optimisation. The important habit is to notice operations inside loops and folds. Building a List by prepending and reversing once, or using a builder, expresses the same result without repeatedly walking the accumulated prefix.

Build a List without repeated append traversalscala 2.13
def normalise(values: List[String]): List[String] = {
  val reversed = values.foldLeft(List.empty[String]) {
    case (result, raw) => raw.trim.toLowerCase :: result
  }

  reversed.reverse
}

// For a general indexed sequence:
val names: Vector[String] = Vector("Ada", "Grace")
val updated = names.updated(1, "Hopper")

Set and Map encode identity decisions

A Set removes duplicates according to equality and hashing. A Map does the same for keys, retaining one value for each key. Choosing the key is therefore a domain decision: grouping accounts by display name is unsafe when names are not unique, while AccountId may provide the identity the operation requires.

Map.apply throws when a key is absent. Use get when absence must remain explicit, getOrElse when there is a genuine default, or an operation-specific Either when a missing entry is an error that callers need to understand.

Conversions can silently discard information. Calling toMap on pairs with duplicate keys keeps one value, and calling toSet removes repeated elements. Make that collapse deliberate, particularly when it happens at an application boundary.

Make a missing key part of the resultscala 2.13
sealed trait LookupError
object LookupError {
  final case class AccountNotFound(id: AccountId) extends LookupError
}

def find(
  accounts: Map[AccountId, Account],
  id: AccountId
): Either[LookupError, Account] =
  accounts.get(id).toRight(LookupError.AccountNotFound(id))

map, flatMap and collect describe different transformations

map produces one output for each input while preserving the collection shape where the operation permits it. flatMap lets each input produce zero, one or many outputs and concatenates them. filter retains original elements that satisfy a predicate.

collect combines filtering and transformation through a partial function. It is useful when only some input cases produce an output, especially with a sealed hierarchy. collectFirst stops at the first defined result rather than building every match.

Avoid chaining several full traversals without considering the data size and clarity. One collect can be clearer than filter followed by map, but combining unrelated business decisions into a dense partial function is not an improvement.

Select and transform one passscala 2.13
sealed trait Event
final case class AccountOpened(id: AccountId) extends Event
final case class AccountClosed(id: AccountId) extends Event
final case class AuditRecorded(message: String) extends Event

val affectedAccounts: List[AccountId] = events.collect {
  case AccountOpened(id) => id
  case AccountClosed(id) => id
}

val firstClosure: Option[AccountId] = events.collectFirst {
  case AccountClosed(id) => id
}

Prefer total folds to unsafe reductions

foldLeft carries an explicit initial value through a collection and works for empty input. reduce combines elements without an initial value and throws for an empty collection. If emptiness is possible, the initial value in a fold makes the intended result visible.

Scala 2.13 provides groupMap and groupMapReduce for common grouping operations. They avoid constructing an intermediate grouped collection only to map or reduce each group afterwards.

A fold can still hide too much. If the accumulator contains unrelated flags, maps and counters, name a small state type or split the operation. The goal is to make the transition from one state to the next obvious.

Aggregate with an explicit empty resultscala 2.13
val total: BigDecimal =
  payments.foldLeft(BigDecimal(0))(_ + _.amount)

val namesByStatus: Map[Status, List[String]] =
  accounts.groupMap(_.status)(_.name)

val totalsByAccount: Map[AccountId, BigDecimal] =
  payments.groupMapReduce(_.accountId)(_.amount)(_ + _)

Views and iterators postpone work in different ways

Most ordinary collection transformations are strict: map creates its result before returning. View builds a reusable description of transformations and evaluates elements when the view is consumed. Iterator is also lazy but stateful and normally single-use.

A view can avoid large intermediate collections when several transformations are chained, but it can also repeat expensive work if consumed more than once. Convert with toVector, toList or another strict collection when the result needs a stable materialised value.

In Scala 2.13, mapValues and filterKeys return views. That catches code migrated from older collections where the apparent Map was often treated as a realised result. Use view.mapValues(...).toMap when lazy projection is not the intended contract.

Make laziness and materialisation explicitscala 2.13
val activeNames: Vector[String] = accounts
  .view
  .filter(_.isActive)
  .map(_.name.trim)
  .filter(_.nonEmpty)
  .toVector

val balances: Map[AccountId, BigDecimal] =
  accountsById.view.mapValues(_.balance).toMap

val lines: Iterator[String] = source.getLines()
// Consuming lines advances the iterator.

Interop makes mutability especially important

Scala 2.13 uses scala.jdk.CollectionConverters._ to convert between Scala and Java collections. Conversions are generally adapters over the same underlying collection rather than deep immutable copies. Mutating a Java list obtained from a mutable Scala buffer can therefore be visible from both sides.

Converting an immutable Scala collection to a Java interface does not make mutation valid. If an API requires ownership of a mutable collection, create an explicit copy at that boundary rather than allowing implementation assumptions to leak through the application.

Review collection code by asking about meaning first and cost second: are ordering, uniqueness and absence correct; is mutation owned; can the input be empty; is evaluation strict or deferred; and does an operation inside a loop repeatedly traverse accumulated data?

Treat conversion and copying as separate decisionsscala 2.13
import scala.jdk.CollectionConverters._

val scalaNames: Vector[String] = Vector("Ada", "Grace")
val javaView: java.util.List[String] = scalaNames.asJava

// When a Java API will mutate the list, give it an owned copy.
val mutableCopy = new java.util.ArrayList[String](javaView)
legacyApi.populate(mutableCopy)