Understanding Union Types in Scala 3

Discover the advantages of union types and flow typing in Scala 3. Learn how union types reduce verbosity and runtime overhead compared to Either, and how flow typing enhances type safety and code readability. Learn to handle null values explicitly.

Understanding Union Types in Scala 3
Choosing the right path: Simplifying code with Union Types in Scala 3.

Introduction

We often need to write functions that return different data types. For example, we might need to communicate an error that occurred because of invalid input. Using exceptions for such cases is inappropriate because these outcomes are expected and should be handled as part of normal program flow.

A common approach in functional programming languages for handling such cases is using Algebraic Data Types (ADTs). One such ADT is Either, which allows a function to return one of two possible types: typically, a success or an error value.

Whatever approach we choose, we will force the code using our function to deal with boxed values, making it more lengthy and probably forcing it to use patterns like Functors or Monads to work with wrapped values.

Let's consider an example where we need to fetch user data. The function might return the user data if the operation is successful or an error message if something goes wrong.

We'll use Either maintaining the convention of Left being the error and Right the successful result:

Scala 3

def fetchUserData(userId: String): Either[String, User] = {
  if (userId.isEmpty) Left("User ID cannot be empty")
  else Right(User(userId, "John Doe"))
}
        

This pattern effectively communicates that the function can result in two different types of outcomes.

Unfortunately, it boxes our result value, creating disadvantages varying from Runtime overhead to forcing the code using the function to be more verbose than otherwise necessary.

This article discusses these disadvantages and explains how Union Types in Scala 3 provide an elegant and efficient alternative.


Disadvantages of Using Either

While Either is a powerful and widely-used construct in Scala 2 and other functional programming languages, it comes with some drawbacks; to be fair, the drawbacks would be the same for any approach wrapping the actual return values within a box.

Runtime Overhead

A significant disadvantage  Either is the runtime overhead associated with it. Each Left and Right instance creates an additional object at Runtime.

It could result in even more boxing and unboxing if we use patterns like Functor or Monads because they give us a standardized approach to process wrapped values and wrap their results.

The Scala compiler and JVM will try to optimize and minimize that overhead. Still, in high-throughput systems where performance is critical, the best optimization is to avoid creating unnecessary objects.

Verbosity

Another drawback of using Either is the verbosity it introduces into the code. Every time we handle the result of a function that returns an Either, we need to check whether the result is an explicitly Left (error) or a Right(success). This constant pattern matching can make the code lengthy and more complex to read:

Scala 3

val result = fetchUserData("12345")

if (result.isLeft) {
  val error = result.left.get
  println(s"Error: $error")
} else {
  val user = result.right.get
  println(s"User: ${user.name}")
}
        

Or we could choose to use Pattern Matching to unbox the values:

Scala 3

fetchUserData("12345") match {
  case Left(error) => println(s"Error: $error")
  case Right(user) => println(s"User: ${user.name}")
}
        

The last option is more idiomatic; wrapping the values might be the right solution in some circumstances.


Union Types

Union types are a type system construct allowing a variable to hold values of several specified types. They provide a way to express that a variable or return value can have different data types.

Algebraic Data Types or Generic Algebraic Data Types are sometimes described as Union Types in technical articles. However, we should not confuse the ability to encode them in a language with complete Union Type support.

First-class Union Type support demands minimal Runtime overhead, meaning there should not be any value boxing and few, if any, syntactical overhead when working with variables typed using unions.

As of the publication of this article, only Scala 3 and TypeScript have full support for Union Types; in all the other languages, we have to encode them using different features like ADTs or Generics.

Union Types in Scala 3

Scala 3 introduces union types directly into the language, providing a more natural and concise way to handle multiple possible types for a value or function return type. The union type is represented using the | operator:

Scala 3

def fetchUserData(userId: String): String | User = {
  if (userId.isEmpty) "User ID cannot be empty"
  else User(userId, "John Doe")
}

val result = fetchUserData("12345")
        

No wrapper is involved; the variable can hold values of different types directly, and all the type resolution happens at compile time.

Scala won't allow us to use the methods of any of the types directly, forcing us to check which of the type of the object we are dealing with before having any meaningful interactions with it:

Scala 3

def process(value: Int | String): String = {
  if (value.isInstanceOf[Int]) {
    val intValue = value.asInstanceOf[Int]
    s"Received an integer: $intValue"
  } else {
    val strValue = value.asInstanceOf[String]
    s"Received an error: $strValue"
  }
}

      
val result: String | User = fetchUserData("12345")

result match {
  case user: User => println(s"User: ${user.name}")
  case error: String => println(s"Error: $error")
}          
        

The code is already more efficient because we do not box the values, but it still has some syntactical overhead. There are some scenarios in which Scala 3 can also minimize the overhead and even the need for intermediate variables.

Union Types and Flow Typing

Flow typing is a powerful feature that enhances the usability of union types. It allows the compiler to automatically deduce which type in a union a variable holds based on the context, eliminating the need for intermediate variables and explicit type casts. This makes our code more concise, readable, and less error-prone.

With flow typing, the compiler handles type narrowing automatically, making the code cleaner and reducing boilerplate.

This allows us to transform the process function from the previous example into:

Scala 3

def process(value: Int | String): String = {
  if (value.isInstanceOf[Int]) {
    s"Received an integer: ${value + 1}"
  } else {
    s"Received a string: ${value}"
  }
}
        

Flow typing significantly improves the handling of union types by allowing the compiler to infer and narrow types within specific scopes. It reduces the need for intermediate variables and explicit type casts, making the code more concise, readable, and type-safe.

A motorcyclist with the Scala logo speeding ahead of a truck with the Java logo on a dynamic road, symbolizing the efficiency of union types.
Union Types: Speeding Ahead of Traditional Constructs

Union Types and Nulls

One common programming challenge is handling null values safely and effectively. Scala 3's union types and flow typing offer a powerful way to deal with nulls explicitly, reducing the risk of null pointer exceptions and making the code more robust.

If we activate Explicit Nulls, we'll get a compiler error if we try to assign null to a variable typed as any sub-type of AnyRef.

This is the ideal behavior in most cases. However, there might be situations in which we still need to allow nulls, for example, when interacting with Java or Scala 2 libraries.

We can allow nulls by creating a Union Type of the type we care about and Null, which in Scala 3 is a singleton type:

Scala 3

def getUserInput: String | Null =
  // Simulating user input that can be a valid string or null
  val inputs = Array("Hello, World!", null, "Scala 3 is great!")
  inputs(scala.util.Random.nextInt(inputs.length))
        
def processInput(input: String | Null): String =
  if input == null then
    log.error("No input received.")
  else
    println(s"User input: ${input.take(10)}")

val userInput = getUserInput
processInput(userInput)
      

The above example demonstrates:

  • Explicit Null Handling: The union type String | Null makes it clear that the input can be null, ensuring that nulls are handled explicitly.
  • Type Safety: Flow typing ensures that input is treated as a String within the else branch, preventing null pointer exceptions.
  • Simplified Code: There is no need for explicit type casts or intermediate variables, making the code cleaner and more readable.
  • Improved Robustness: By handling nulls explicitly, the code is more robust and less prone to runtime errors.

Conclusion

In this article, we explored the advantages of using union types in Scala 3, highlighting how they simplify handling multiple possible types compared to traditional approaches like Either.

Union types eliminate the need for wrapping and unwrapping values, reducing verbosity and runtime overhead. Additionally, we saw how flow typing further enhances union types by allowing the compiler to narrow down types within conditional scopes, making the code more concise, readable, and type-safe.

We also examined how union types and flow typing can handle null values explicitly, ensuring robust and maintainable code by minimizing the risk of null pointer exceptions.

In a future article, we'll explore intersection types, another exciting feature in Scala 3. Stay tuned!


Addendum: A Special Note for Our Readers

I decided to delay the introduction of subscriptions. You can read the full story here.

In the meantime, I'll be accepting donations. I would appreciate it if you consider a donation, provided you can afford it:

Every donation helps me offset the site's running costs and an unexpected tax bill. Any amount is greatly appreciated.

Also, if you are looking for a fancy way to receive 2025, I am renting my timeshare in Phuket to cover part of the unexpected costs.

Anantara Vacation Club Phuket Mai Khao $2000.00/night
Phuket, Thailand / Posting R1185060

Or share our articles and links page in your social media posts.

Finally, some articles have links to relevant goods and services; it won't cost you more if you buy through them. If you like programming swag, please visit the TuringTacoTales Store on Redbubble.

Take a look. Maybe you can find something you like: