Scala 3: Well-designed Object-Oriented Type Hierarchies

Dean Wampler
Feb 8, 2021 · 5 min read

This post discusses a few changes and additions in Scala 3 that make designing robust, object-oriented type hierarchies a little easier.

Icicles © 2021, Dean Wampler

Join me for Scala Love in the City, February 13th. I’m doing a talk about Scala 3’s contextual abstractions.

For an even more concise summary of most of the notable changes in Scala 3, see my new Scala 3 Highlights page.

You can start reading the rough draft of Programming Scala, Third Editionon the O’Reilly Learning Platform. The first half or so of the chapters are available. I am refining them still, but any feedback is welcome!

While object-oriented programming (OOP) is much maligned, Scala embaces it as a useful tool for solving many design problems. Fortunately, Scala also offers several features to encourage “best practices”. Scala 2 offered these features.

Declaring a parent type sealed means that only subtypes defined in the same file are allowed. This is a way of implementing algebraic data types, where the types obey well defined properties.

A classic example is Option[T], which is declared as sealed abstract class, with two concrete subtypes, Some[T] and None, representing the only two possible cases, either an item of type T exists or it doesn’t.

“Mixin” traits make it easy to compose behaviors, rather than add too much state and behavior in a type definition.

If you override a concrete method, you must add the override keyword. This catches a few possible surprises. If misspell a method name you think you are overriding, so you aren’t actually overriding anything, the compiler won’t let you use this keyword. Conversely, if you think your subtype defines a new method, but a subsequent version of the supertype adds a method with the same signature, your subtype will now fail to compile.

However, these are relatively rare issues. My favorite benefit is to treat override as a warning; overriding methods is an easy way to introduce bugs. Should you call the parent method or just replace it? If you call it, should you call it first and do something with the returned value or should you call it last? Answering these questions incorrectly for the particular method can cause the contract of the original method to be broken in the override. Scala can’t verify you did the right thing.

Unfortunately, the languages like Java have made us accustomed to overriding methods. We do this with toString all the time, but really, it should be rare. Use the Template Method Pattern to design methods that avoid the need for overriding.

You can declare a type or method final, which prevents extension, i.e., subtyping of a type and overriding of a method, respectively. This is used less often than it should be, because you have to take the initiative. It’s all to easy to omit final.

Scala 3 Enhancements

Scala 3 adds several features that refine our ability to design types.

The Scala 2 enumeration syntax was a bit awkward to use and hard to remember. Scala 3 introduces a more concise enum syntax with improved features. It’s also a suitable tool for implementing algebraic data types as a more concise alternative to sealed hierarchies.

Here’s how Option could be implemented with an enum, omitting all the usual methods, for simplicity.

In the first variant, the parent type of Some is inferred to be Option[T], while the parent type of the None value is inferred to be Option[Nothing]. From the Dotty documentation, “Generally, all covariant type parameters of the enum class are minimized in a compiler-generated extends clause whereas all contravariant type parameters are maximized. If Option was non-variant, you would need to give the extends clause of None explicitly.” The second variant shows explicitly the typing relationships.

Notice what happens when we use them:

First, unless we import Some and None, we have to refer to them using Option. Second, the return types for o1 and o2 are Option[...], whereas for the sealed hierarchy Option type, o1 would have the type Some[Int] and o2 would have the type None. (Note that Scala 3.0 uses the Scala 2.13 library, so this will still be true in Scala 3, at least for a few point releases.)

Once we import Some and None, then the inferred types for o3 and o4 are Some[Int] and None.type, respectively.

We could add all the usual methods we have for the library’s Option. Also, like the library Option, we can’t create our own subtypes of the enum Option or its members. We can also pattern match with them, just the like the library case class Some and case object None.

In fact, enums are implemented as sealed classes that extend the scala.reflect.Enum trait.

I discussed the new open keyword in a previous post. Now the default is for the compiler to disallow subtyping concrete types, unless they are declared open or the language extension adhocExtensions is enabled. (Actually, it will become the default as of Scala 3.1…) You don’t have to declare such types final, but you still have an “escape hatch” when you need to override a concrete type (e.g., to create test doubles).

Export clauses are “inverses” of import clauses, sort-of. Suppose you declare a type that instantiates instances of other types inside its body and you want some of the members in those instances to be part of the public interface of the compound type. Note that if you use mixin traits or a parent type, this happens in the usual, automatic way. In Scala 2, you would have to write boilerplate “delegator” members that just invoke the instance members.

Scala 3 adds an export clause that allows you to expose these members directly. Here’s an example that starts with a few “domain” types for a user name and password, then a trait for authentication and a concrete class that uses a directory service:

The object Service declares a private instance of the DirectoryAuthenticate, then uses an export clause to make the instance apply method part of the Service API, but with the name authenticate. The method isAuthenticated is exported as is.

Here’s Service in action:

Conclusion

I’ve always felt that Scala’s thoughtful combination of OOP and FP leverages the best of both worlds. In particular, because Scala has a carefully designed type system, a lot of thought has gone into how to encourage type designs that avoid many of the mistakes of applied OOP, like sloppy, poorly-defined types.

Scala 3 adds to the feature set that encourages careful type design. Sealed hierarchies, with enums as an alternative mechanism, encourage the use of algebraic data types and empower safe and effective pattern matching. Final types and methods prevent undesired extension, but now concrete types will be final by default (evolving over several 3.X releases), but they can be declared open when subtyping is desirable. Export clauses eliminate another form of boilerplate, delegation methods, when exposing members of instances nested within a compound type.

For an even more concise summary of most of the notable changes in Scala 3, see my new Scala 3 Highlights page.

You can start reading the rough draft of Programming Scala, Third Editionon the O’Reilly Learning Platform. The first half or so of the chapters are available. I am refining them still, but any feedback is welcome!

Scala 3

What’s new in Scala version 3

Scala 3

A series of posts on Scala version 3, what’s new and why, and how to use its new features effectively. For more details, visit http://programming-scala.org/.

Dean Wampler

Written by

The person who’s wrong on the Internet. ML/AI and functional programming enthusiast at Domino Data Lab. Speaker, author, aspiring photographer.

Scala 3

A series of posts on Scala version 3, what’s new and why, and how to use its new features effectively. For more details, visit http://programming-scala.org/.