1. Overview
In this tutorial, we’ll learn how to represent Type Disjunction or Union Types in Scala.
There are no built-in union types in Scala except in Scala3. However, we can represent union type functionality by using Scala’s language features such as Either and Scala’s typeclasses.
We’ll look into each one of those in detail with the help of relevant examples.
2. Union Types
As the name suggests, a Union Type denotes a type that is the union of two or more types. For example, we can define a union type of Int and String values and use it as a parameter of a function that expects either an integer or string value as input.
Scala is a strongly and statically typed language. Therefore, it requires that we define the parameter types and return types at the time of code compilation. This reduces the flexibility of functions to accept a parameter of different types. For example, we cannot pass a String value to a function accepting an Int parameter.
The traditional way of dealing with such problems is by defining overloaded functions. However, this is not always the simplest approach. As the number of types in the union increases, overloading can get complicated and become difficult to manage. Union Types are the most appropriate solution to such problems.
3. Either Approach
The Scala standard library provides the Either type, which is used to represent the disjoint union of two types. Either is generally used for use cases similar to the Option type. The Right denotes the happy path and the Left denotes the exception case. Since Scala version 2.12, Either is right-biased, therefore, it’s also usable as a monad.
Either can be used to define a disjoint union type of two types. Let’s construct a function that takes a parameter whose type is either Int or String and prints out the parameter type:
def isIntOrString(t: Either[Int, String]): String ={
t match {
case Left(i) => "%d is an Integer".format(i)
case Right(s) => "%s is a String".format(s)
}
}
println(isIntOrString(Left(10))) // prints "10 is an Integer"
println(isIntOrString(Right("hello"))) // prints "hello is a String"
The major limitation of using the Either type for a disjoint union is that it can be used only for two types. Either is a boxed type, therefore, we need to wrap values as instances of either Right or Left.
4. Arbitrary-arity Union Type
So far, we’ve seen how to create union types consisting of just two types. What if we want to create a union type of more than two types? One way to deal with this is to do nesting. Nesting quickly gets complicated and becomes cumbersome to maintain.
Fortunately, there’s another solution for defining union types in Scala using typeclasses and implicits.
Let’s start by declaring a typeclass for a union type of three types – Integer, String, and Boolean – as a sealed trait so that we can prevent any un-wanted extensions to our typeclass:
sealed trait IntOrStringOrBool[T]
object IntOrStringOrBool {
implicit val intInstance: IntOrStringOrBool[Int] =
new IntOrStringOrBool[Int] {}
implicit val strInstance: IntOrStringOrBool[String] =
new IntOrStringOrBool[String] {}
implicit val boolInstance: IntOrStringOrBool[Boolean] =
new IntOrStringOrBool[Boolean] {}
}
Now, let’s declare a function that takes our freshly created union type and extracts the type using a simple pattern matching:
def isIntOrStringOrBool[T: IntOrStringOrBool](t: T): String = t match {
case i: Int => "%d is an Integer".format(i)
case s: String => "%s is a String".format(s)
case b: Boolean => "%b a Boolean".format(b)
}
Then, we can invoke our function using values of different types:
println(isIntOrStringOrBool(10)) // prints "10 is an Integer"
println(isIntOrStringOrBool("hello")) // prints "hello is a String"
println(isIntOrStringOrBool(true)) // prints "true is a Boolean"
It’s now easy to extend our union type to include one more type by simply adding another implicit instance to our typeclass.
We can further generalize the above approach by abstracting the types using generic types:
sealed trait AOrB[A, B]
object AOrB {
implicit def aInstance[A,B](a: A) = new AOrB[A, B] {}
implicit def bInstance[A,B](b: B) = new AOrB[A, B] {}
}
def isIntOrString[T <% String AOrB Int](t: T): String = t match {
case i: Int => "%d is an Integer".format(i)
case s: String => "%s is a String".format(s)
}
5. Union Types in Scala3 (Dotty)
Type intersection exists in the standard Scala library using the with operator, but there’s no union operator. The Scala3 (Dotty project) is trying to address this by introducing new union types and intersection types represented using the operators | and &.
Both joined union types and disjoint union types can be defined using these. A Scala3 Union Type contains all values of both types. Unlike Either, Union Types are unboxed, and they also have arbitrary arity.
Let’s write a solution for our above problem using the Scala3 union type operator:
def isIntOrString(t:Int|String|Boolean)=t match {
case i: Int => "%d is an Integer".format(i)
case s: String => "%s is a String".format(s)
case b: Boolean => "%b is a Boolean".format(b)
}
println(isIntOrString(10)) //prints "10 is an Integer"
println(isIntOrString("hello")) // prints "hello is a String"
println(isIntOrString(true)) // prints "true is a Boolean"
6. Conclusion
In this tutorial, we’ve learned different ways to implement union types in Scala using Either and typeclasses.
First, we learned how to create union types of two types using the Either type, along with its advantages and disadvantages, with the help of examples.
Then, we saw that we can define union types using typeclasses and implicits to overcome the limitations of the Either approach.
As always, the code can be found over on GitHub.