Scala 3: Extension Methods

4 minute read

This article is for the curious folks going from Scala 2 to Scala 3 - we’re going to explore extension methods, one of the most exciting features of the upcoming version of the language.

As for requirements, two major pieces are important:

1. Background

In Scala 2, we had this concept of adding methods to types that were already defined elsewhere, and which we couldn’t modify (like String, or Int). This technique was called “type enrichment”, which was a bit boring, so people came up with the more tongue-in-cheek “pimping”, which bordered on slang-ish, so the term commonly used is “extension methods”, because that’s what we’re doing.

In Scala 2, we can add extension methods to existing types via implicit classes. Let’s say we have a case class

case class Person(name: String) {
    def greet: String = s"Hi, I'm $name, nice to meet you."
}

In this case, then the following implicit class

implicit class PersonLike(string: String) {
    def greet: String = Person(string).greet
}

would enable us to call the greet method on a String

"Daniel".greet

and the code would compile — that’s because the compiler will silently turn that line into

new PersonLike("Daniel").greet

In other words, the greet method is an extension to the String type, even though we did not touch the String type at all.

Libraries like Cats (which I teach here on the site) do this all the time.

2. Proper Extensions

In Scala 3, the implicit keyword, although fully supported (for this version), is being replaced:

So how are extension methods declared?

For our scenario with the string taking an extra method greet (which is person-like), we can write an explicit extension clause:

extension (str: String) 
    def greet: String = Person(str).greet

And now we can call the greet method as before:

"Daniel".greet

3. Generic Extensions

Much like implicit classes, extension methods can be generic. Let’s say somebody wrote a new binary tree data structure

sealed abstract class Tree[+A]
case class Leaf[+A](value: A) extends Tree[A]
case class Branch[+A](left: Tree[A], right: Tree[A]) extends Tree[A]

and we have no access to the source code. On the other hand, we want to add some methods that we normally use on lists, for example. A filter, for instance, would be nice. Here’s how we could write it:

extension [A](tree: Tree[A])
  def filter(predicate: A => Boolean): Boolean = tree match {
    case Leaf(value) => predicate(value)
    case Branch(left, right) => left.filter(predicate) || right.filter(predicate)
  }     

So the method is generic, in that it can “attach” to any Tree[T].

An even better feature is that the method itself can be generic. Let’s see how we can write a map method on trees:

extension [A](tree: Tree[A])
  def map[B](func: A => B): Tree[B] = tree match {
    case Leaf(value) => Leaf(func(value))
    case Branch(left, right) => Branch(left.map(func), right.map(func))
  }

By the way, we can group both extension methods together under a single extension clause:

extension [A] (tree: Tree[A]) {
  def filter(predicate: A => Boolean): Boolean = tree match {
    case Leaf(value) => predicate(value)
    case Branch(left, right) => left.filter(predicate) || right.filter(predicate)
  }

  def map[B](func: A => B): Tree[B] = tree match {
    case Leaf(value) => Leaf(func(value))
    case Branch(left, right) => Branch(left.map(func), right.map(func))
  }
}

(used curly braces, but indentation regions will also work)

4. Extensions in the Presence of Givens

Or, more precisely, in the presence of using clauses.

Let’s see how we can attach a new method sum to our new binary tree data structure, if our type argument is numeric — in other words, if we have a given Numeric[A] in scope:

extension [A](tree: Tree[A])(using numeric: Numeric[A]) {
  def sum: A = tree match {
    case Leaf(value) => value
    case Branch(left, right) => numeric.plus(left.sum, right.sum)
  }
}

At this point, we can safely use a Tree[Int] and call this sum method on it:

val tree = Branch(Leaf(1), Leaf(2))
val three = tree.sum

The using clause might be present in the extension clause, or in the method signature itself:

// works exactly the same
extension [A](tree: Tree[A]) {
  def sum(using numeric: Numeric[A]): A = tree match {
    case Leaf(value) => value
    case Branch(left, right) => numeric.plus(left.sum, right.sum)
  }
}

Or even in both places, if you’d like.

Conclusion

In this article, we’ve deconstructed the mechanism of extension methods. This feature, coupled with the given/using combo, allows for some powerful abstractions including type classes, DSLs and many more.

I’m pretty confident that Scala 3 will rock. We may have some contention here and there — and I absolutely hate the 3-spaces indentation which I will not follow if it becomes “convention” — but overall, Scala is getting more mature, more expressive, easy and fun to read and write. Which is what a language should be.

Tags:

Updated: