Part 1: Application of literal and dependent object types in Scala 2.13
A central issue with many dynamic and statically typed languages is how to constraint the values of input parameters of type String. Even though the problem of enumerated String literals is effectively solved by enum in Java, enum class in Kotlin and Algebraic Data Type in Scala, many maintainers of legacy libraries and their end-users still suffer from unconstrained String literals for a couple of reasons. First, changing the type of a parameter from String to enum breaks API compatibility. This backward-incompatible change makes it harder for the library end-users to upgrade to the new version, as it inevitably forces them to rewrite from one to many lines of code dependent on String literals. Second, as enums are closed for modification and extension, end-users of a library have to come up with workarounds that are difficult to evolve. All of these issues might turn our software into hardware.
Software was invented to be “soft”. It was intended to be a way to easily change the behavior of machines. If we’d wanted the behavior of machines to be hard to change, we would have called it hardware
[Robert C. Martin, Clean Architecture].
In this post, we will start with a naive calculator that fits into 7 lines of code. The purpose of this simple program is to demonstrate how parametrization of a function with unconstrained String literals lures engineers to write not reliable software and how type-unsafe functions can become safe with an alternative design, which combines literal types, dependent-object types, and type class instances.
Example: unsafe calculator in the wild
Every one of us was new to programming at some point in time and went beyond classical “Hello World” application by writing yet another calculator with four basic combinators: multiplication, division, addition, and subtraction. In this blog post, all these operations depend on two additional numbers. To keep the example simple to reason, the calculator function accepts an operator of type String and based on the operator value results in function literal, accepting numeric values and performing an appropriate calculation.
If input String literal is “multiply”, the result is a function that accepts two parameters of type Int responsible for performing multiplication. A similar principle applies to the “divide”, “add” and “subtract” operations performing appropriate computations. If operator value is not supported, the calculator function throws IllegalArgumentException.
As you might notice, the calculator function is not fully type-safe. Even though it is described in terms of types which are closed for an extension, it is type-unsafe at the unconstrained operator.
If we make a typo at the call site of the function in the name of the operation (e.g. sabtract instead of subtract), it can only be detected by IDE or at runtime. Even though this kind of bugs can easily be caught by unit tests in a library, nothing prevents from introducing this kind of mistakes by the library end-users.
The only way to avoid them is by having correct assumptions and covering code with unit tests.
Can we do better?
Let's explore whether we can turn assumption-driven programming into type-driven and make code fully tested by a compiler, where bugs related to unsupported String literals are detected during compilation time and never deployed to production. In Scala 2.13 and Scala 3 (aka Dotty), we can make it possible by combining literal types with dependent-object types. Additionally, by incorporating type class instances we will achieve the same semantics of function application with one undeniable advantage: unsupported by calculator operations of type String will never be compiled.
Making unsafe calculator safe
One way to achieve desired functionality is by combining dependent-object types with literal types. First, let’s express a function via Dependent Object Types (DOT). This trick will allow us to refer to function types through the type members. DOT is intended to be a core calculus for modeling Scala. Its distinguishing feature is abstract type members, fields in objects that hold types rather than values. One way to initialize type members is to bind them with type parameters from a type constructor. This type of initialization pattern allows referring to the constructor’s parameters through type member aliases.
Having FunctionD abstraction described in terms of Input and Output abstract type members, we can wrap it with another abstraction, LiteralFunctionD, dependent on String literal type and its own abstract type members, wired with FunctionD type parameters. In this way, we can say that abstract type members of FunctionD are wired with a literal type parameter of LiteralFunctionD.
Literal types bridge the gap between types and values, and their presence in Scala has over the years allowed Scala programmers to explore techniques that would typically only be available in languages, such as Agda or Idris, with support for full-spectrum dependent types.
Literal types have been introduced in Typelevel Scala, Scala 2.13 and Scala 3 (aka Dotty).
As defining a concrete type of LiteralFunctinD becomes boilerplate, let’s introduce LDP type constructor to make it easier to reason about LiteralFunctionD type parametrization.
Type constructors are types that take one or more types as parameters. If you apply the type constructor to type arguments you get a first-order type. So LDP is a type constructor.
Having all the necessary types defined, its time to implement a factory method that will allow us to define literal type dependent functions.
We are finally ready to define four basic calculator operations, dependent on string literals in a type-safe way, and the calculator function, dependent on implicit arguments, that will later be injected by Scala compiler at a call site:
If you compare the application of the type-safe version listed below with unsafe one mentioned at the beginning, you will encounter one significant difference: the first operator argument of type String is moved to the type position.
If we make a typo in a calculator operator name or provide unsupported one, the code does not compile:
And this is exactly what we wanted to achieve! With literal types, we can lift the type safety of our applications to a higher level like never before. But what about making the calculator application even more concise by removing apply function? Can we improve the implementation in such a way that we can simply apply calculator[“add”](6,2)?
It turns out that we can, but with some limitations, that I will describe in the second part of the article. Stay tuned!