Collections Architecture & Custom Collections
|
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). The collections library is the 2.13 library shared by all of Scala 3, so the 3.9 LTS line (3.9.0, 2026-09-03) and 3.3 LTS (3.3.8) behave the same for everything shown here (see Scala 3.9 LTS versus 3.10).
The Scala collections have hundreds of operations on dozens of classes, and a filter on a List returns a List, on a Map
a Map, on a LazyList a lazy LazyList, on a TreeSet a TreeSet that is still sorted. That consistency is not magic and not duplication: it comes
from a small set of template traits that implement each operation once. This page explains that design, then uses it to write
a custom collection and custom operations, and finishes with the changes you meet when migrating from the 2.12 collections.
It builds on the trailhead scenario, a toolkit that plans hikes on a network of mountain trails, and assumes the tour in
Collections Overview and
Collection Operations; the cost of each class is on
Collection Performance & Equality.
The problem the design solves
If every collection class implemented every operation, the library would be an enormous amount of nearly identical code that could drift apart. The principal design objective is to define each operation in as few places as possible, and the template traits have to cope with these differences between collections:
-
some operations return the same concrete collection type (
filteron aList[Int]is aList[Int]); -
some return the same collection type with another element type (
mapon aList[Int]can produce aList[String]); -
some collections have one element type (
List[A]), others two (Map[K, V]); -
some operations return a different collection depending on the element type:
mapon aMapreturns aMapif the function returns a pair, and anIterableotherwise; -
some transformations need an extra implicit parameter (
mapon aSortedSetneeds anOrdering); -
some collections are strict (
List) and others non-strict (View,LazyList), and a default implementation must preserve that.
The next program shows each of these in the static types of the results.
//> using scala 3.10.0
import scala.collection.immutable.{SortedSet, TreeMap, ListMap}
@main def run(): Unit =
val lengths = List(12.5, 7.0, 3.2)
val names = Map("Refugio" -> 12.5, "Pico" -> 7.0)
// same concrete type
val a: List[Double] = lengths.filter(_ > 5)
// same type constructor, new element type
val b: List[String] = lengths.map(_.toString)
// Map.map: result depends on whether the function returns a pair
val c: Map[String, Int] = names.map((k, v) => k -> v.toInt)
val d: Iterable[String] = names.map((k, v) => s"$k $v")
// SortedSet.map needs an Ordering for the new element type
val e: SortedSet[Int] = SortedSet(3.2, 7.0).map(_.toInt)
// TreeMap.map: sorted map stays sorted if keys have an Ordering
val f: TreeMap[Int, String] = TreeMap("b" -> 1, "a" -> 2).map((k, v) => v -> k)
println(List(a, b, c, d, e, f).mkString("\n"))
List(12.5, 7.0)
List(12.5, 7.0, 3.2)
Map(Refugio -> 12, Pico -> 7)
List(Refugio 12.5, Pico 7.0)
TreeSet(3, 7)
TreeMap(1 -> b, 2 -> a)
The declared types are checked by the compiler: filter keeps List[Double], map changes the element type but keeps List,
names.map((k, v) => k -> v.toInt) keeps Map, and the same call returning a String falls back to a plain Iterable
(a List at run time). SortedSet.map keeps the set sorted only because an Ordering[Int] exists. If it does not, the compiler
rejects the call.
//> using scala 3.10.0
import scala.collection.immutable.SortedSet
class Waypoint(val name: String)
@main def run(): Unit =
val s = SortedSet(1, 2, 3)
val t = s.map(i => Waypoint(i.toString))
println(t)
-- [E172] Type Error: ./b2.scala:8:42
8 | val t = s.map(i => Waypoint(i.toString))
| ^
|No implicit Ordering[Waypoint] found to build a SortedSet[Waypoint]. You may want to upcast to a Set[Int @uncheckedVariance] first by calling `unsorted`..
|I found:
| scala.math.Ordering.comparatorToOrdering[Waypoint](
| /* missing */summon[java.util.Comparator[Waypoint]])
The message comes from the SortedSetOps overload and suggests the fix: go through unsorted to get a plain Set, which needs no ordering.
Template traits: IterableOps
Operations are not defined in the classes (List, Vector) but in template traits, whose names end in Ops. The one for
Iterable abstracts over three things. A simplified view of its header and of two operations:
trait IterableOps[+A, +CC[_], +C]:
def filter(p: A => Boolean): C = ...
def map[B](f: A => B): CC[B] = ...
-
Ais the element type; -
CCis the type constructor of the collection, used when the element type changes (map,flatMap,collect,zip…); -
Cis the collection type, used when the element type is kept (filter,take,drop,tail,init…).
A leaf collection instantiates the parameters with itself. For List they are A, List and List[A]:
trait List[+A] extends Iterable[A], IterableOps[A, List, List[A]]
Abstracting over CC is a use of higher-kinded types
(CC takes one type argument), and the covariance of A is the subject of Variance.
The operations in these traits are the methods listed on Collection Operations.
Four branches of template traits
Map[K, V] has two type parameters, so its constructor cannot be passed as the one-argument CC of IterableOps. A second family of
template traits exists for maps, and two more cover the sorted collections, which need an Ordering that IterableOps knows nothing about.
| Kind | Not sorted | Sorted |
|---|---|---|
|
|
|
|
|
|
MapOps extends IterableOps[(K, V), Iterable, C], so a Map inherits two overloads of map: the one from MapOps, which
returns CC[K2, V2] (a Map), and the one from IterableOps, which returns Iterable[B]. When the function returns a pair both
apply and the more specific MapOps one wins, so you get a Map; otherwise only the IterableOps one applies. This is the
"same-result-type" principle: wherever possible, a transformation yields the same kind of collection. SortedSetOps and
SortedMapOps add overloads that take a using Ordering, so that mapping a SortedSet to a type with an ordering gives a SortedSet again.
Scala 2 syntax differs only on the surface (implicit instead of using, braces instead of indentation); the traits are the same.
Strict and non-strict collections
Some collections evaluate their elements eagerly (List, Set, Vector) and others delay (View, LazyList). The default
implementation of a transformation therefore has to preserve the strictness of the class it is inherited by. The library does
this by implementing the operations on top of a non-strict View. A simplified filter and map:
trait IterableOps[+A, +CC[_], +C]:
def iterableFactory: IterableFactory[CC]
protected def fromSpecific(coll: IterableOnce[A]): C
def filter(p: A => Boolean): C = fromSpecific(View.Filter(this, p))
def map[B](f: A => B): CC[B] = iterableFactory.from(View.Map(this, f))
View.Filter(this, p) describes the filter without running it. fromSpecific turns that description into a concrete C: a
List decides to traverse it right away, a LazyList wraps it. iterableFactory.from does the same for a different element
type. View itself is an Iterable with a single abstract method, iterator, so its elements are only evaluated when the iterator is traversed.
//> using scala 3.10.0
import scala.collection.View
@main def run(): Unit =
var evaluated = 0
val xs = List(1, 2, 3, 4)
val lazyView = xs.view.map { x => evaluated += 1; x * 2 }
println(s"after view.map: evaluated = $evaluated")
val strict = xs.map { x => evaluated += 1; x * 2 }
println(s"after List.map: evaluated = $evaluated")
evaluated = 0
val ll = LazyList.from(1).map { x => evaluated += 1; x * 2 }
println(s"LazyList.map before use: evaluated = $evaluated")
println(ll.take(3).toList)
println(s"after taking 3: evaluated = $evaluated")
// static types of the same operation on strict and non-strict collections
val a: List[Int] = xs.filter(_ > 1)
val b: View[Int] = xs.view.filter(_ > 1)
val c: LazyList[Int] = LazyList(1, 2, 3).filter(_ > 1)
println(s"$a / ${b.toList} / ${c.toList}")
// partition on a View: each half is a view that traverses the source again when it is used
var traversals = 0
val src = View.fromIteratorProvider { () => traversals += 1; Iterator(1, 2, 3, 4) }
val (evens, odds) = src.partition(_ % 2 == 0)
println(s"${evens.toList} ${odds.toList} traversals = $traversals")
after view.map: evaluated = 0
after List.map: evaluated = 4
LazyList.map before use: evaluated = 0
List(2, 4, 6)
after taking 3: evaluated = 3
List(2, 3, 4) / List(2, 3, 4) / List(2, 3)
List(2, 4) List(1, 3) traversals = 2
Neither view.map nor LazyList.map runs the function until elements are demanded (and LazyList only evaluates the first three
elements, not the infinite tail), while List.map runs it immediately. The last line shows the cost of the view-based default:
a partition on a view traverses its source once per half, which is why strict collections override partition.
When strictness is preferable: Builder and StrictOptimized
Some operations are more efficient strict (partition would have to traverse a non-strict source twice) and some cannot be non-strict at all
(groupBy has to evaluate the elements). For those, the library offers a second implementation style based on a Builder instead of a View:
package scala.collection.mutable
trait Builder[-A, +C]:
def addOne(elem: A): this.type
def result(): C
You add an element with b.addOne(x) (or b += x) and take the collection with b.result(). The template traits expose it
through newSpecificBuilder (same element type, result type C) and iterableFactory.newBuilder (any element type, result CC[B]).
Traits whose names start with StrictOptimized (StrictOptimizedIterableOps, StrictOptimizedSeqOps, StrictOptimizedMapOps,
StrictOptimizedSetOps, StrictOptimizedSortedSetOps and StrictOptimizedSortedMapOps) override the transformation operations to use
builders. The guideline: if a collection is strict, mix the matching StrictOptimized trait in; if an operation need not be strict,
leave it non-strict, or view-like behaviour will surprise users of lazy collections.
Factories: IterableFactory, MapFactory, Factory and BuildFrom
Because there are four branches of operations, there are four branches of factories. IterableFactory[CC] builds CC[A] from
any IterableOnce[A] (from) and offers empty, newBuilder and apply; MapFactory[CC] does the same for two type
parameters; and SortedIterableFactory, SortedMapFactory and EvidenceIterableFactory add the evidence (an Ordering, a ClassTag). The
companion objects of the standard collections are those factories, which is why List.from(xs), Vector.from(xs) and Map.from(pairs) all exist.
The factories that the user of a library function sees are two small traits, used in using clauses:
-
Factory[-A, +C]says "a way to build a collectionCof elementsA`" without naming `C. It is the argument ofto(xs.to(Vector)works because a collection companion is implicitly converted to aFactory) and ofIterator.to. -
BuildFrom[-From, -A, +C]says "given an existing collection of typeFrom, a way to build aCfrom elementsA`". Because it looks at the source collection, it can preserve its dynamic characteristics (the `Orderingof aSortedSet, the element type of anArray, the kind ofString).Future.sequence,Future.traverseand the operations written in Transforming any collection use it.
trait Factory[-A, +C]:
def fromSpecific(it: IterableOnce[A]): C
def newBuilder: Builder[A, C]
trait BuildFrom[-From, -A, +C]:
def fromSpecific(from: From)(it: IterableOnce[A]): C
def newBuilder(from: From): Builder[A, C]
Source: Scala API: Factory and Scala API: BuildFrom
Prefer fromSpecific over newBuilder when you can: a builder always evaluates, while fromSpecific can stay lazy for a lazy
collection type.
Writing a custom collection
To make a new collection profit from every predefined operation with the right result types, you choose a base type, implement
a few abstract members, and (if you want the operations to return your type) mix in the Ops template and supply a factory.
This section builds three collections for the trailhead scenario: an Iterable (the last few waypoints a hiker has passed), a Seq
(a sequence of waypoints with a domain method) and a Map (an index of trail names with a prefix query).
A capped collection: Iterable
The first collection keeps at most capacity waypoints and drops the oldest ones. The first task is the supertype. A Seq
is tempting, but a Seq promises (xs ++ ys).size == xs.size + ys.size, which a capped collection cannot keep, so the right base is
immutable.Iterable. A first version only implements iterator (required by Iterable) and the new operation appended:
//> using scala 3.10.0
package trailhead
import scala.collection.immutable
/** The last `capacity` waypoints a hiker has passed, oldest dropped first. */
final class Recent1[A] private (val capacity: Int, items: Vector[A]) extends immutable.Iterable[A]:
def this(capacity: Int) = this(capacity, Vector.empty)
def appended[B >: A](elem: B): Recent1[B] =
val grown = items :+ elem
Recent1[B](capacity, if grown.size > capacity then grown.drop(1) else grown)
def :+[B >: A](elem: B): Recent1[B] = appended(elem)
def iterator: Iterator[A] = items.iterator
override def className = "Recent1"
@main def run(): Unit =
val r = Recent1[String](3) :+ "Refugio" :+ "Collado" :+ "Pico" :+ "Cumbre"
println(r)
println(r.size)
println(r.map(_.length))
val shortest = r.take(2)
println(shortest)
val t: immutable.Iterable[String] = r.take(2)
println(t.getClass.getName)
Recent1(Collado, Pico, Cumbre)
3
List(7, 4, 6)
List(Collado, Pico)
scala.collection.immutable.$colon$colon
The collection works, and map gives a List because immutable.Iterable implements map with the default List factory. The
interesting line is take(2): it returns a plain List, not a Recent1, because Iterable returns an Iterable, whose default is a List.
Overriding take alone would not be enough. There are over fifty methods that return a collection (drop, filter, init…), and
the templates exist so you do not override any of them.
The fix is the protocol described above. The final version below:
-
extends
IterableOps[A, Recent, Recent[A]], which fixes the result types (Recent[A]fortake,filter…,Recent[B]formap); -
overrides
iterableFactoryto return a factory that buildsRecent(the factory also carries the capacity); -
mixes in
IterableFactoryDefaults, which implementsfromSpecific,newSpecificBuilderandemptyby forwarding toiterableFactory, so you do not write them; -
mixes in
StrictOptimizedIterableOps, because the collection is strict; -
overrides
knownSize(the size is always known) for performance.
//> using scala 3.10.0
package trailhead
import scala.collection.{IterableFactory, IterableFactoryDefaults, IterableOnce, IterableOps, StrictOptimizedIterableOps, immutable, mutable}
/** The last `capacity` waypoints a hiker has passed, oldest dropped first. */
final class Recent[A] private (val capacity: Int, items: Vector[A])
extends immutable.Iterable[A],
IterableOps[A, Recent, Recent[A]],
IterableFactoryDefaults[A, Recent],
StrictOptimizedIterableOps[A, Recent, Recent[A]]:
def this(capacity: Int) = this(capacity, Vector.empty)
def appended[B >: A](elem: B): Recent[B] =
val grown = items :+ elem
new Recent[B](capacity, if grown.size > capacity then grown.drop(1) else grown)
def :+[B >: A](elem: B): Recent[B] = appended(elem)
def apply(i: Int): A = items(i)
def iterator: Iterator[A] = items.iterator
override def knownSize: Int = items.size
override def className = "Recent"
override val iterableFactory: IterableFactory[Recent] = RecentFactory(capacity)
class RecentFactory(capacity: Int) extends IterableFactory[Recent]:
def empty[A]: Recent[A] = new Recent[A](capacity)
def from[A](source: IterableOnce[A]): Recent[A] =
source match
case r: Recent[?] if r.capacity == capacity => r.asInstanceOf[Recent[A]]
case _ => (newBuilder[A] ++= source).result()
def newBuilder[A]: mutable.Builder[A, Recent[A]] =
new mutable.ImmutableBuilder[A, Recent[A]](empty):
def addOne(elem: A): this.type =
elems = elems :+ elem
this
object Recent extends RecentFactory(capacity = 3)
@main def run(): Unit =
val r = Recent("Refugio", "Collado", "Pico", "Cumbre")
println(r)
val taken: Recent[String] = r.take(2)
println(taken)
val lengths: Recent[Int] = r.map(_.length)
println(lengths)
println(r.filter(_.length > 4))
println(r.partition(_.startsWith("C")))
println(List(1, 2, 3, 4, 5).to(Recent))
println(r.zip(r.map(_.length)))
println(r.groupBy(_.length).map((len, rs) => len -> rs))
println(r.drop(1).headOption)
Recent(Collado, Pico, Cumbre)
Recent(Collado, Pico)
Recent(7, 4, 6)
Recent(Collado, Cumbre)
(Recent(Collado, Cumbre),Recent(Pico))
Recent(3, 4, 5)
Recent((Collado,7), (Pico,4), (Cumbre,6))
HashMap(6 -> Recent(Cumbre), 7 -> Recent(Collado), 4 -> Recent(Pico))
Some(Pico)
Every transformation now returns the right type: take, filter and partition return Recent, map returns Recent[Int], zip returns
Recent of pairs, and to(Recent) converts another collection (List(1, 2, 3, 4, 5).to(Recent) keeps the last three). groupBy
returns a Map of Recent, since a map is the only reasonable result there. Three details of the code deserve attention:
-
the code uses
new Recent[B](...)because the companion objectRecentextends the factory and so defines its ownapply, which would captureRecent[B](...)as a call to the varargs factory and not the constructor (the compiler reports a type mismatch); -
RecentFactoryis a class with acapacityparameter, andobject Recent extends RecentFactory(capacity = 3)is a ready-made default instance; it is the factory that decides the capacity of the results; -
fromreturns the source unchanged when it is already aRecentof the same capacity, avoiding a copy.
A sequence: Seq and IndexedSeq
A sequence needs a different set of traits: SeqOps branch (here through IndexedSeqOps), SeqFactory. The following Waypoints class
is a sequence backed by a Vector, with a domain method (returnTrip) and the same result-type behaviour as Vector.
//> using scala 3.10.0
package trailhead
import scala.collection.{SeqFactory, StrictOptimizedSeqFactory, StrictOptimizedSeqOps, IterableFactoryDefaults, IterableOnce, immutable, mutable}
/** An immutable sequence of waypoints backed by a Vector, with a domain method. */
final class Waypoints[+A] private (underlying: Vector[A])
extends immutable.AbstractSeq[A],
immutable.IndexedSeq[A],
immutable.IndexedSeqOps[A, Waypoints, Waypoints[A]],
StrictOptimizedSeqOps[A, Waypoints, Waypoints[A]],
IterableFactoryDefaults[A, Waypoints]:
override def iterableFactory: SeqFactory[Waypoints] = Waypoints
def length: Int = underlying.length
def apply(i: Int): A = underlying(i)
override def className = "Waypoints"
/** A domain method: the same path walked back. */
def returnTrip: Waypoints[A] = new Waypoints(underlying.reverse)
object Waypoints extends StrictOptimizedSeqFactory[Waypoints]:
def empty[A]: Waypoints[A] = new Waypoints(Vector.empty)
def from[A](source: IterableOnce[A]): Waypoints[A] =
source match
case w: Waypoints[A] => w
case other => new Waypoints(Vector.from(other))
def newBuilder[A]: mutable.Builder[A, Waypoints[A]] =
Vector.newBuilder[A].mapResult(v => new Waypoints(v))
@main def run(): Unit =
val w = Waypoints("Refugio", "Collado", "Pico")
println(w)
val sorted: Waypoints[String] = w.sorted
println(sorted)
val mapped: Waypoints[Int] = w.map(_.length)
println(mapped)
println(w.reverse.returnTrip)
println(w.tail.headOption)
println(w ++ List("Cumbre"))
println(w :+ "Cumbre")
println(w == List("Refugio", "Collado", "Pico"))
println(w.patch(1, List("Lago"), 1))
println(w.zipWithIndex)
println(w.groupBy(_.length))
println(w.to(Waypoints) eq w)
Waypoints(Refugio, Collado, Pico)
Waypoints(Collado, Pico, Refugio)
Waypoints(7, 7, 4)
Waypoints(Refugio, Collado, Pico)
Some(Collado)
Waypoints(Refugio, Collado, Pico, Cumbre)
Waypoints(Refugio, Collado, Pico, Cumbre)
true
Waypoints(Refugio, Lago, Pico)
Waypoints((Refugio,0), (Collado,1), (Pico,2))
HashMap(7 -> Waypoints(Refugio, Collado), 4 -> Waypoints(Pico))
true
sorted, map, ++, :+, patch and zipWithIndex return Waypoints, == with a List is true, because equality is
defined for the sequence category as a whole (see Collection Performance & Equality),
and groupBy returns a Map of Waypoints. The class is covariant (+A), like the standard immutable collections. The pieces are:
-
the supertypes:
immutable.AbstractSeq(the abstract class the standard sequences extend, which avoids interface dispatch),immutable.IndexedSeqand the templateIndexedSeqOps[A, Waypoints, Waypoints[A]]; -
iterableFactoryof typeSeqFactory, implemented by the companionWaypoints, which extendsStrictOptimizedSeqFactory, the sequence-branch version ofIterableFactory; -
the abstract members of a sequence,
lengthandapply, andIterableFactoryDefaultsandStrictOptimizedSeqOpsas before.
A map: the third branch
A map needs MapOps. When the key type is fixed (here String) and the collection is not generic in two parameters, the usual
trick is to instantiate CC with the standard immutable.Map, and C with your own type, so that operations that keep keys and values return your
type and operations that change them fall back to a standard Map. The next class indexes trails by name and adds a prefix query.
//> using scala 3.10.0
package trailhead
import scala.collection.{IterableOnce, immutable, mutable}
import scala.collection.immutable.{TreeMap, MapOps}
/** A trail-name index: an immutable map with String keys and a prefix query. */
final class TrailIndex[+V] private (underlying: TreeMap[String, V])
extends immutable.AbstractMap[String, V],
MapOps[String, V, immutable.Map, TrailIndex[V]],
collection.StrictOptimizedIterableOps[(String, V), immutable.Iterable, TrailIndex[V]]:
// Operations that keep the key and value types return a TrailIndex
override protected def fromSpecific(coll: IterableOnce[(String, V @scala.annotation.unchecked.uncheckedVariance)]): TrailIndex[V] =
TrailIndex.from(coll)
override protected def newSpecificBuilder: mutable.Builder[(String, V @scala.annotation.unchecked.uncheckedVariance), TrailIndex[V]] =
TrailIndex.newBuilder[V]
override def empty: TrailIndex[V] = TrailIndex.empty[V]
def get(key: String): Option[V] = underlying.get(key)
def iterator: Iterator[(String, V)] = underlying.iterator
def removed(key: String): TrailIndex[V] = new TrailIndex(underlying.removed(key))
def updated[V1 >: V](key: String, value: V1): TrailIndex[V1] = new TrailIndex(underlying.updated(key, value))
/** All entries whose key starts with the prefix. */
def withPrefix(prefix: String): TrailIndex[V] =
new TrailIndex(underlying.rangeFrom(prefix).takeWhile((k, _) => k.startsWith(prefix)))
override def className = "TrailIndex"
object TrailIndex:
def empty[V]: TrailIndex[V] = new TrailIndex(TreeMap.empty[String, V])
def from[V](source: IterableOnce[(String, V)]): TrailIndex[V] =
source match
case ti: TrailIndex[V @unchecked] => ti
case other => (newBuilder[V] ++= other).result()
def apply[V](entries: (String, V)*): TrailIndex[V] = from(entries)
def newBuilder[V]: mutable.Builder[(String, V), TrailIndex[V]] =
TreeMap.newBuilder[String, V].mapResult(m => new TrailIndex(m))
@main def run(): Unit =
val idx = TrailIndex("camino-norte" -> 12.5, "camino-sur" -> 8.0, "collado" -> 3.2, "pico" -> 7.0)
println(idx.withPrefix("cam"))
val long: TrailIndex[Double] = idx.filter((_, km) => km > 7.5)
println(long)
val rounded = idx.map((k, km) => k -> km.toInt)
println(rounded)
val lengths = idx.map((k, km) => km)
println(lengths)
println(idx.removed("pico").keys)
println(idx.updated("lago", 1.1).get("lago"))
TrailIndex(camino-norte -> 12.5, camino-sur -> 8.0)
TrailIndex(camino-norte -> 12.5, camino-sur -> 8.0)
Map(camino-norte -> 12, camino-sur -> 8, collado -> 3, pico -> 7)
List(12.5, 8.0, 3.2, 7.0)
Set(camino-norte, camino-sur, collado)
Some(1.1)
filter and withPrefix return TrailIndex, removed and updated too, and map returns a plain Map when the function returns a pair
(the key type may change) and an Iterable otherwise. The code needs uncheckedVariance on the element type of fromSpecific
and newSpecificBuilder, because the protected members of a covariant class use V in an invariant position; see
Variance for why that annotation is safe here. For a fully generic map,
implement MapOps[K, V, CC, C] with a MapFactory[CC] in the same way as the sequence.
Writing operations on any collection
Custom operations are different from custom collections. You want to add sumBy or intersperse to every collection, without
writing a new collection. The three cases are consuming, producing and transforming. They are all
extension methods and
using clauses; the contextual-abstraction background is not repeated here.
Consuming any collection
To consume a collection, take an IterableOnce[A] if you traverse once, or an Iterable[A] if more than once. The extension below sums
a function applied to the elements, and works on collections, iterators and, in Scala 3, also on String and Array.
//> using scala 3.10.0
package trailhead
case class Trail(name: String, km: Double, ascent: Int)
extension [A](coll: IterableOnce[A])
def sumBy[B: Numeric](f: A => B): B =
val it = coll.iterator
val num = summon[Numeric[B]]
var result = num.zero
while it.hasNext do result = num.plus(result, f(it.next()))
result
@main def run(): Unit =
val trails = Vector(Trail("Ridge", 12.5, 900), Trail("Lake", 6.0, 300))
println(trails.sumBy(_.km))
println(Set(1, 2, 3).sumBy(_ * 2))
println(Iterator("ab", "c").sumBy(_.length))
println("hello".sumBy(_.toInt))
println(Array("ab", "c").sumBy(_.length))
println(List(1, 2).view.sumBy(_ + 1))
18.5
12
3
532
3
5
The official guide explains that such an extension does not work on String and Array, because they are not part of the hierarchy and
Scala 2 can apply only one implicit conversion. On Scala 3 the example above runs on both, because the extension receiver is converted to an IterableOnce by the
Predef conversions ("hello" to a WrappedString, an Array[String] to an ArraySeq). What conversion does not give you is the
original type of the receiver, which a transformation needs to return, and the complete list of collection-like types. For that the library has
IsIterable[Repr] (and IsSeq and IsMap), with instances for every type that can be viewed as an IterableOps, including String, Array and the views.
//> using scala 3.10.0
package trailhead
import scala.collection.generic.IsIterable
extension [Repr](repr: Repr)(using iter: IsIterable[Repr])
def sumBy[B: Numeric](f: iter.A => B): B =
val it = iter(repr).iterator
val num = summon[Numeric[B]]
var result = num.zero
while it.hasNext do result = num.plus(result, f(it.next()))
result
@main def run(): Unit =
println(Vector(1, 2, 3).sumBy(_ * 10))
println("hello".sumBy(_.toInt))
println(Array("ab", "c").sumBy(_.length))
println(Map("a" -> 1, "b" -> 2).sumBy((_, v) => v))
println(List(1, 2).view.sumBy(_ + 1))
60
532
3
3
5
The shape is extension [Repr](repr: Repr)(using iter: IsIterable[Repr]), with iter.A as the element type and iter(repr) as
the conversion to IterableOps. Use IsSeq when the operation only makes sense on sequences (it also works for SeqView, which does not extend Seq), and IsMap for maps and MapView.
Producing any collection
When a library function must produce a collection and the user should pick which one, take a Factory in a using clause. The functions below
never name a concrete collection; the result type chooses it.
//> using scala 3.10.0
package trailhead
import scala.collection.Factory
import scala.collection.immutable.TreeSet
import scala.util.Random
case class Trail(name: String, km: Double)
// The caller picks the result collection; the library never names a concrete type
def namesOf[C](trails: Seq[Trail])(using factory: Factory[String, C]): C =
factory.fromSpecific(trails.iterator.map(_.name))
// A generator of values that can build any collection of them
def randomKms[CC[_]](n: Int, seed: Long)(using factory: Factory[Double, CC[Double]]): CC[Double] =
val rng = Random(seed)
factory.fromSpecific(Iterator.fill(n)(math.round(rng.nextDouble() * 200) / 10.0))
@main def run(): Unit =
val trails = Seq(Trail("Pico", 7.0), Trail("Lago", 4.5), Trail("Pico", 9.1))
val asList: List[String] = namesOf(trails)
val asSet: Set[String] = namesOf(trails)
val asSorted: TreeSet[String] = namesOf(trails)
val asArray: Array[String] = namesOf(trails)
println(s"$asList $asSet $asSorted ${asArray.mkString(",")}")
val kmList: List[Double] = randomKms(4, 7L)
val kmSet: Set[Double] = randomKms(4, 7L)
val kmLazy: LazyList[Double] = randomKms(4, 7L)
println(s"$kmList $kmSet ${kmLazy.toList}")
List(Pico, Lago, Pico) Set(Pico, Lago) TreeSet(Lago, Pico) Pico,Lago,Pico
List(14.6, 15.0, 7.0, 17.9) Set(14.6, 15.0, 7.0, 17.9) List(14.6, 15.0, 7.0, 17.9)
The expected type picks List, Set, a sorted TreeSet and an Array for namesOf, and for randomKms the CC[_] parameter of
the signature is inferred from the declared type. The function passes fromSpecific an Iterator, so a lazy target such as LazyList is built
lazily and a strict one evaluates it; this is the recommended way to build the result, and it is the reason the guide advises fromSpecific
over a builder. An Array needs a ClassTag for the factory to exist, which the compiler finds because the element type is known.
Transforming any collection
Transforming is consuming plus producing: use IsSeq to consume, and BuildFrom to build a result of the same kind as the input. The
operation below puts a separator between the elements of any sequence and returns the same kind of collection, String and Array included.
//> using scala 3.10.0
package trailhead
import scala.collection.BuildFrom
import scala.collection.generic.IsSeq
extension [Repr](repr: Repr)(using seq: IsSeq[Repr])
def intersperse[B >: seq.A, That](sep: B)(using bf: BuildFrom[Repr, B, That]): That =
val elems = seq(repr).iterator
val withSeparators = elems.zipWithIndex.flatMap((x, i) => if i == 0 then Iterator(x) else Iterator(sep, x))
bf.fromSpecific(repr)(withSeparators.to(Iterable))
@main def run(): Unit =
val a: Vector[Int] = Vector(1, 2, 3).intersperse(0)
val b: List[String] = List("Refugio", "Pico").intersperse(">")
val c: String = "abc".intersperse('-')
val d: Array[Int] = Array(1, 2, 3).intersperse(0)
val e: LazyList[Int] = LazyList(1, 2).intersperse(9)
println(s"$a $b $c ${d.mkString("[", ",", "]")} ${e.toList}")
println(Seq(1, 2, 3).view.intersperse(0).toList)
Vector(1, 0, 2, 0, 3) List(Refugio, >, Pico) a-b-c [1,0,2,0,3] List(1, 9, 2)
List(1, 0, 2, 0, 3)
Vector gives Vector, List gives List, a String gives a String, an Array[Int] an Array[Int], a LazyList a LazyList, and the
separator type B may be a supertype of the element type. BuildFrom[Repr, B, That] is what makes the That result type follow from Repr.
In 2.12 the same job was done by CanBuildFrom, which every transformation took as an implicit parameter; see the next section.
Migrating from the 2.12 collections
The 2.13 library is a redesign, and Scala 3 uses it unchanged, so code written for 2.12 (or Scala 2.11 and earlier) meets these changes. The main ones:
| 2.12 | 2.13 and Scala 3 |
|---|---|
|
They are aliases of |
Transformations take an implicit |
No |
|
Only |
|
|
|
Removed. Use |
|
|
|
They return a |
Views extend their collection ( |
Views no longer extend their collection types, and work reliably. |
Immutable |
Reimplemented with the CHAMP encoding. |
|
Removed; use |
Parallel collections in the standard library |
A separate module ( |
|
Only on sequences and iterators: |
On Scala 3 many of these produce real messages. The first program uses two removed forms (the Scala 2 to[Set] syntax and breakOut):
//> using scala 3.10.0
@main def run(): Unit =
val xs = List(3, 1, 2)
val a = xs.to[Set]
val b: List[Int] = xs.map(_ + 1)(collection.breakOut)
-- [E056] Syntax Error: ./m1.scala:4:10
4 | val a = xs.to[Set]
| ^
| Missing type parameter for Set
-- [E008] Not Found Error: ./m1.scala:5:46
5 | val b: List[Int] = xs.map(_ + 1)(collection.breakOut)
| ^^^^^^^^^^^^^^^^^^^
| value breakOut is not a member of collection
2 errors found
The following uses the deprecated spellings that still compile, with -deprecation to show each message:
//> using scala 3.10.0
//> using option -deprecation
@main def run(): Unit =
val s = Stream(1, 2, 3)
val t: Traversable[Int] = List(1, 2)
val m = Map("a" -> 1).mapValues(_ + 1)
val it = List(1, 2).toIterator
val v = List(1, 2).toStream
val l = List(1, 2).iterator.toTraversable
val s2 = List(1).toIterable
-- Deprecation Warning: ./m4.scala:4:10
4 | val s = Stream(1, 2, 3)
| ^^^^^^
|value Stream in package scala is deprecated since 2.13.0: Use LazyList instead of Stream
-- Deprecation Warning: ./m4.scala:5:9
5 | val t: Traversable[Int] = List(1, 2)
| ^^^^^^^^^^^
|type Traversable in package scala is deprecated since 2.13.0: Use Iterable instead of Traversable
-- Deprecation Warning: ./m4.scala:6:24
6 | val m = Map("a" -> 1).mapValues(_ + 1)
| ^^^^^^^^^^^^^^^^^^^^^^^
|method mapValues in trait MapOps is deprecated since 2.13.0: Use .view.mapValues(f). A future version will include a strict version of this method (for now, .view.mapValues(f).toMap).
-- Deprecation Warning: ./m4.scala:7:22
7 | val it = List(1, 2).toIterator
| ^^^^^^^^^^^^^^^^^^^^^
|method toIterator in trait IterableOnceOps is deprecated since 2.13.0: Use .iterator instead of .toIterator
-- Deprecation Warning: ./m4.scala:8:21
8 | val v = List(1, 2).toStream
| ^^^^^^^^^^^^^^^^^^^
|method toStream in trait IterableOnceOps is deprecated since 2.13.0: Use .to(LazyList) instead of .toStream
-- Deprecation Warning: ./m4.scala:9:30
9 | val l = List(1, 2).iterator.toTraversable
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
|method toTraversable in class IterableOnceExtensionMethods is deprecated since 2.13.0: Use .iterator.to(Iterable) instead
-- Deprecation Warning: ./m4.scala:10:19
10 | val s2 = List(1).toIterable
| ^^^^^^^^^^^^^^^^^^
|method toIterable in trait Iterable is deprecated since 2.13.7: toIterable is internal and will be made protected; its name is similar to `toList` or `toSeq`, but it doesn't copy non-immutable collections
7 warnings found
A class that was removed outright is a hard error:
//> using scala 3.10.0
@main def run(): Unit =
val ms = scala.collection.mutable.MutableList(1)
-- [E008] Not Found Error: ./m5.scala:3:36
3 | val ms = scala.collection.mutable.MutableList(1)
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
| value MutableList is not a member of scala.collection.mutable
1 error found
The Seq alias and varargs
The most invasive change is the meaning of Seq. Because scala.Seq is now immutable, a method that took Seq[Int] no longer accepts an
ArrayBuffer, and the varargs parameter Int* demands an immutable sequence too.
//> using scala 3.10.0
import scala.collection.mutable.ArrayBuffer
def total(xs: Seq[Int]): Int = xs.sum
def count(xs: Int*): Int = xs.size
@main def run(): Unit =
val buf = ArrayBuffer(1, 2, 3)
println(total(buf))
println(count(buf*))
-- [E007] Type Mismatch Error: ./m2.scala:9:16
9 | println(total(buf))
| ^^^
| Found: (buf : scala.collection.mutable.ArrayBuffer[Int])
| Required: Seq[Int]
| longer explanation available when compiling with `-explain`
-- [E007] Type Mismatch Error: ./m2.scala:10:16
10 | println(count(buf*))
| ^^^
| Found: (buf : scala.collection.mutable.ArrayBuffer[Int])
| Required: Seq[Int] | Array[? <: Int]
| longer explanation available when compiling with `-explain`
2 errors found
Three ways to migrate a library signature: take collection.Seq for parameters (accepts every sequence, mutable or not), take collection.Seq
and return immutable.Seq (Postel’s law), or use immutable sequences on both sides. The program below shows the first form and the call-site fixes for varargs (buf.toSeq*, which copies only if the
sequence is not already immutable, and arr*, which is accepted for an array), and the MapView behaviour:
//> using scala 3.10.0
import scala.collection.mutable.ArrayBuffer
import scala.collection
def total(xs: collection.Seq[Int]): Int = xs.sum
def count(xs: Int*): Int = xs.size
@main def run(): Unit =
val buf = ArrayBuffer(1, 2, 3)
println(total(buf))
println(count(buf.toSeq*))
val arr = Array(4, 5)
println(count(arr.toSeq*))
println(count(arr*))
val scores = Map("a" -> 1, "b" -> 2)
val view = scores.view.mapValues(_ * 10)
println(view)
println(scores.view.mapValues(_ * 10).toMap)
println(scores.view.filterKeys(_ == "a").toMap)
println(xs0.getClass)
def xs0 = List(1).iterator
6
3
2
2
MapView(<not computed>)
Map(a -> 10, b -> 20)
Map(a -> 1)
class scala.collection.StrictOptimizedLinearSeqOps$$anon$1
Cross-building with scala-collection-compat
A library that must support 2.12 and later at the same time can use the scala-collection-compat module, which backports the 2.13 API
(xs.to(List), view.mapValues, LazyList…) to 2.11 and 2.12 and ships Scalafix rules (Collection213Upgrade and Collections213CrossCompat) that rewrite old code. The same
source then compiles on both. The program below is for 2.12.21 and printed the same output on 2.13.18 (it is not needed on Scala 3, where the 2.13 API is built in):
//> using scala 2.12.21
//> using dep org.scala-lang.modules::scala-collection-compat:2.14.0
import scala.collection.compat._
object Main {
def main(args: Array[String]): Unit = {
val xs = List(3, 1, 2, 3)
val asSet = xs.to(Set)
val asVector = xs.to(Vector)
val m = Map("a" -> 1, "b" -> 2).view.mapValues(_ * 10).toMap
println(s"$asSet $asVector $m")
println(scala.util.Properties.versionNumberString)
}
}
Set(3, 1, 2) Vector(3, 1, 2, 3) Map(a -> 10, b -> 20)
2.12.21
The sbt form of the dependency is libraryDependencies += "org.scala-lang.modules" %% "scala-collection-compat" % "2.14.0"; in Scala CLI use
//> using dep org.scala-lang.modules::scala-collection-compat:2.14.0, as in the example. The Scalafix rules themselves were not run here.
For code that is only on Scala 3, no module is needed: scala-collection-compat is meant for cross-building with 2.12 and earlier.
Differences from Java and Kotlin
-
Java has no template traits. The
Collectioninterfaces return fixed interface types (List<E> subList,Collection<E>), and the transformations of the streams API are separate from the collections:stream().map(...).collect(Collectors.toCollection(TreeSet::new)). ACollectorplays the role of aFactory. User-defined collections extendAbstractList,AbstractSetorAbstractMapand getequals,hashCodeandtoStringfrom them, but not typed operations. See The Collections Framework and Streams & Collectors. -
Kotlin has extension functions over
IterableandCollection, so an operation is defined once for all of them, but its result type is fixed:filterandmapon anyIterablereturn aList(on aSettoo), and onlyMap.filterreturns aMap. There is no per-collection result type like Scala’sSortedSet.mapgiving aSortedSet. Producing "any collection" istoCollection(destination). See Collections & Sequences. -
Scala’s design pays for the typed results with a more complex hierarchy to extend; most code never writes a collection and only uses the operations. Defining a new operation for every collection type is the common extension, and an extension method with
IsIterableorIsSeqdoes it in a few lines.
Pitfalls
-
Overriding one operation (
take) in a custom collection instead of mixing inIterableOps: the other fifty still return the base type. -
Forgetting
iterableFactory, or leavingfromSpecificandnewSpecificBuilderpointing at the parent’s defaults: operations compile and return the wrong type, asRecent1showed. -
A companion that extends the factory also defines
apply, which shadows the constructor inRecent[B](...). Usenew. -
Not mixing in
StrictOptimized...Opsin a strict collection: it works but is slower, because the default implementations go through views. -
Extending
Seqfor a collection that does not satisfy theSeqlaws (such assizeof++). -
Writing a library API with
Seqand being surprised that mutable sequences are rejected. Choosecollection.Seqorimmutable.Seqdeliberately. -
Using
Iterable.fromor a builder in aFactory-based function and losing laziness; preferfromSpecific. -
Using
mapValuesand expecting a strict map: the result is a lazy view that re-evaluates the function on each access.
Where the books are outdated
-
Scala 2.12 and earlier books (including the 2nd edition of Programming in Scala) describe
CanBuildFrom,Traversable,GenTraversableLikeand the*Likeand*Builderfamilies. They no longer exist; the*Opstemplates,FactoryandBuildFromreplace them. -
The same books show
breakOut,to[C],Stream,mapValuesreturning aMap, immutableStack,ArrayStackandMutableList. See the migration table above. -
Examples that define a custom collection by extending
SeqLikeorIterableLikewith anewBuilderandcanBuildFromdo not compile on 2.13 or Scala 3. -
The official guide’s remark that an extension method does not work on
StringandArrayis about Scala 2 implicits; on Scala 3 an extension onIterableOncedoes (as shown), though transformations still needIsSeqandBuildFromto keep the receiver’s type. -
The official custom-collection pages show Scala 2 and Scala 3 side by side. Only the Scala 3 syntax is used on this page.
Scala 3.9 LTS versus 3.10
The collections library is the same on every Scala 3 release, and the template traits, factories and BuildFrom have not changed
since 2.13. The programs of this page were compiled and run unchanged on 3.3.8, 3.9.0 and 3.10.0 and printed the same output (the only
difference on 3.3.8 was a JDK warning about sun.misc.Unsafe); the migration examples gave the same compiler messages.