Let’s Talk About the Scala 3 Indentation
Regardless of whether you’re experienced or new to Scala, you’ve probably been confused about Scala syntax inconsistencies and alternatives at least once. In this article, we’ll take a careful, structured look at how Scala 3 adds yet another facility to our ever-expanding set of alternative code styles.
This feature (along with dozens of other changes) is explained in depth in the Scala 3 New Features course.
1. If Expressions
The Scala 2 syntax allows you to write if expressions on multiple lines:
val aCondition = if (2 > 3)
"bigger"
else "smaller"
Of course, that’s not how aesthetics are generally chosen. Some styles include:
// one-liner
val aCondition = if (2 > 3) "bigger" else "smaller"
// java-style
val aCondition2 =
if (2 > 3) {
"bigger"
} else {
"smaller"
}
// compact
val aCondition3 =
if (2 > 3) "bigger"
else "smaller"
To be clear, all the above are still supported in Scala 3. However, we can make do without the braces and even the parentheses of if expressions, while indentation becomes significant.
val anIndentedCondition =
if 2 > 3
"bigger"
else
"smaller"
When we remove the parens around 2 > 3
, indentation becomes significant for this expression. This means that the else
branch must be at least at the indent level of the if
. In real life, mixed indentations for if/else branches (especially in junior/hacked code) are extremely confusing especially in chained conditions, so the compiler helps in this case. The code also looks a bit cleaner than the Java-style version above.
If we want to place the if-branch result on the same line as the condition, we need to add a then
keyword:
// one-liner
val aCondition = if 2 > 3 then "bigger" else "smaller"
// compact
val aCondition =
if 2 > 3 then "bigger"
else "smaller"
That’s it!
2. For Comprehensions
A similar change has been added to other control structures like for
comprehensions. The idea is: if we don’t add braces, indentation becomes significant. Here’s what we used to write:
for {
n <- List(1, 2, 3)
c <- List('a', 'b', 'c')
} yield s"$c$n"
Now, in Scala 3:
for
n <- List(1, 2, 3)
c <- List('a', 'b', 'c')
yield s"$c$n"
Same code, right? That’s because the Scala 2 version also included some proper aesthetics. Without braces, those indents are now mandatory.
No biggie.
3. Indentation regions
In the syntactic analysis compilation step, the compiler adds indentation regions after certain tokens. Translation: as per the official docs, after the keywords if then else while do try catch finally for yield match return
and the tokens = => <-
, we can break the line and write our code one level deeper than the line above. This indentation level will serve as a baseline for the other expressions that we might nest inside. The compiler does it by adding some fake tokens at line breaks (<indent>
or <outdent>
) to keep track of the indentation level without multiple passes over the code.
This means that methods can now be implemented without braces:
def computeMeaningOfLife(year: Int): Int =
println("thinking...")
42 // <-- indent matters, so it's taken under the scope of this method
This part may be particularly confusing. The way I like to talk about it is: imagine the compiler inserted braces between =
and your returned value; in this way, the implementation of the method is a code block, which, obviously, is a single expression whose value is given by its last constituent expression. The significant indentation means, in this case, that we actually have an invisible code block there.
An indentation region is also created when we define classes, traits, objects or enums followed by a colon :
and a line break. This token is now interpreted by the compiler as “colon at end of line”, which is to say “colon then define everything indented”. Examples:
class Animal:
def eat(): Unit
trait Carnivore:
def eat(animal: Animal): Unit
object Carnivore:
def apply(name: String): Carnivore = ???
Similar rules apply for extension methods and given instances (we’ll talk about them in a later article):
given myOrder as Ordering[Int]: // <-- start the indentation region
def compare(x: Int, y: Int) =
if x < y then 1 // notice my new syntax
else if x > y then -1
else 0
Now for the million dollar question: indent with spaces or tabs? Scala 3 supports both, and the compiler is able to compare indentations.
If two lines start with the same number of spaces or the same number of tabs, the indentations are comparable, and the comparison is given by the number of spaces/tabs after. For example, 3 tabs + one space is “less indented” than 3 tabs + 2 spaces. Similarly, 4 spaces + 1 tab is “less indented” than 4 spaces + 2 tabs. Makes sense. If two lines don’t start with the same number of spaces/tabs, they are incomparable, e.g. 3 tabs vs 6 spaces. The compiler always knows the indentation baseline for an indentation region, so if a line is incomparable with the baseline, it’ll give an error.
Here’s the bottom line: just don’t mix spaces with tabs. Pick your camp, fight to the death, but don’t mix ‘em.
4. Function arguments
There’s a common style of writing Scala 2 with one-arg methods:
val aFuture = Future {
// some code
}
which is the same as Future(/* that block of code */)
, which throws some newcomers off. This brace syntax is applicable to any method with a single argument.
Here’s the thing: this one is here to stay. No significant indentation here. We could add support for it with the -Yindent-colons
compiler option, which would allow us to add an end-of-line :
and write the method argument indented:
val nextYear = Future:
2021
or
List(1,2,3).map:
x => x + 1
However, this is not part of the core language rules.
5. The end
Game
This new concept of indentation regions can cause confusions with large blobs of code, particularly in the class definition department - we tend to write lots of code there. Even chained if-expressions can also become hard to read while indented, since code for branches may span several lines, sometimes with whitespace in between them, so it’s hard to pinpoint exactly where each code belongs.
To that end (pun intended), Scala 3 introduced the end
token to differentiate which code belongs to which indentation region:
class Animal:
def eat(): Unit =
if System.currentTimeMillis() % 2 == 0
println("even")
else
println("odd")
end if
end eat
end Animal
Obviously, this example is trivial, but the end
token will definitely prove useful when we have 100x more code in the same file than in the above snippet, when we have lots of indents, and/or lots of whitespace in between lines. The end
token does not influence the compiler, but it was added for our ease of reading code.
6. Conclusion
There’s not much more to it - pretty much everything you need to know about indentation with Scala 3. When I personally looked at the new indentation rules for Scala 3, I personally thought, “what have you done?!”. Come to take a more structured approach, it’s not that bad, and it might actually help. Several people already report that Scala feels faster to write, easier to read and generally more productive with this style. Only time will tell - in any event, if this article made even one person think, “this isn’t as bad as I thought”, then it was a success!