Union Types vs Either: What's the difference?
You may have stumbled on the Either type at some point if you used a functional programming language. Or perhaps the Result type in Rust.
Their purpose it to explicitly embed the possibility of failure into a signature, while also treating errors as values (rather than raising exceptions).
Union types have the same purpose: They allow you to specify different outcomes, and they do not have the "functional overhead".
So then: Why should I use Either[A, B] and perform some unwieldy unboxing, if I can also write A | B instead? How are they different and what do they have to offer?
I will demonstrate the difference in Scala 3, since it supports both constructs.
What Are We Talking About?
Union Types
Union types are a feature (since Scala 3) that let you say "this value can be one of these types" directly in the type system:
val result: String | Int = if someCondition then "error" else 42The type String | Int means the value is either a String or an Int. No wrapper needed, just the raw types joined with |. This is a compile-time construct that exists purely in the type system.
Either
Either[A, B] is a data structure that explicitly wraps a value in one of two cases:
val result: Either[String, Int] = if someCondition then Left("error") else Right(42)By convention, Left holds errors and Right holds success values. You can memorize it like this:
- We read from Left to Right, and we check for errors first (if you are a fan of guard clauses)
- "Right" is a synonym for correct 🙃
Unfortunately, in for-comprehensions (we will see them later), the success value (Right) goes to the left side of the screen.
The Setup
Imagine we're building an invoice system. We have three operations that can fail in different ways:
case class User(id: String, name: String, vault_key: String)
enum UserFetchError:
case DatabaseError
case UserNotFound
enum VaultFetchError:
case VaultNotFound
case AccessDenied
enum UserDescriptionError:
case InvalidUserId
case InvalidUsername(reason: String)
The Union Type Approach
With union types, our functions return the success type or the error type directly:
def fetchUser(userId: String): User | UserFetchError = ???
def fetchVaultItems(user: User): List[String] | VaultFetchError = ???
def invoiceIdentifier(user: User): String | UserDescriptionError = ???
def fullInvoice(invoiceDescription: String, items: List[String]): String =
s"Invoice for $invoiceDescription:\n- " + items.mkString("\n- ")Imagine the function bodies of these functions performing some DB operations and validity checks. They may end up producing a desired result, or some error, depending on which path they take. The last function always returns a String, so it signals it will simply work.
Clean signatures! But how do we chain these operations?
Caveman Error Handling
Imagine a function that needs to call all three functions in sequence to generate a result (or an error) and each one of the functions can fail. At the end it takes the three pieces of information and generates an invoice via fullInvoice() (an infallible function).
Here is what it may look like with traditional error handling:
type AnyError = UserFetchError | VaultFetchError | UserDescriptionError
def generateInvoice(userId: String): String | AnyError =
val user = fetchUser(userId)
if user.isInstanceOf[UserFetchError] then
return user.asInstanceOf[UserFetchError]
val actualUser = user.asInstanceOf[User]
val items = fetchVaultItems(actualUser)
if items.isInstanceOf[VaultFetchError] then
return items.asInstanceOf[VaultFetchError]
val actualItems = items.asInstanceOf[List[String]]
val description = invoiceIdentifier(actualUser)
if description.isInstanceOf[UserDescriptionError] then
return description.asInstanceOf[UserDescriptionError]
val actualDescription = description.asInstanceOf[String]
fullInvoice(actualDescription, actualItems)Union types with caveman error checking
The only real issue here are the copious .asInstanceOf calls. They are a type-safety nightmare waiting to happen and effectively remove your safety net while refactoring.
Luckily there are alternatives that are not only safer but also a lot more pleasing to look at.
Before we continue to the Either solution, I want to show how this code can look like in Scala if you want to, without making any changes to any signatures:
def generateInvoiceMatchCaveman(userId: String): String | AnyError =
fetchUser(userId) match
case err: UserFetchError => err
case user: User => fetchVaultItems(user) match
case err: VaultFetchError => err
case items: List[String] => invoiceIdentifier(user) match
case err: UserDescriptionError => err
case description: String => fullInvoice(description, items)Union types with pattern matching for error handling
Whether this style is more beautiful is up to the reader, but at least it's a lot safer.
I also find it noteworthy, how the entire function became a singular expression, that makes the three nested levels of failure very obvious. Scala is quite unique with its postfix match operator that allows you to chain it as much as you want.
The Either Approach
With Either, our functions wrap results explicitly:
def fetchUser(userId: String): Either[UserFetchError, User] = ???
def fetchVaultItems(user: User): Either[VaultFetchError, List[String]] = ???
def invoiceIdentifier(user: User): Either[UserDescriptionError, String] = ???
def fullInvoice(invoiceDescription: String, items: List[String]): String =
s"Invoice for $invoiceDescription:\n- " + items.mkString("\n- ")Elegant For Comprehension
Now we can chain operations beautifully, using a for comprehension:
def generateInvoice(userId: String): Either[AnyError, String] =
for
user <- fetchUser(userId)
items <- fetchVaultItems(user)
identifier <- invoiceIdentifier(user)
invoice <- Right(fullInvoice(identifier, items))
yield invoiceThese for comprehesions are a feature exclusive to Scala. They work with many monadic (or pseudo-monadic) types like Either, Option and Try. This is the ultimate payoff for using a little bit more clunky Either[A, B] type rather than A | B
Note: The method fullInvoice cannot fail, or at least my signature claims so. (It always returns a String). Since this for-comprehension always expects an Either (so it can go left or right), I had to wrap fullInvoice into a Right. String is not a subtype of Either. However, Right[String] is - we are expressing that the operation will not fail.
Adding Cross-Cutting Concerns
Need logging? Add a Logger trait and an extension method on Either:
trait Logger:
def error(msg: String): Unit
object Logger:
val console: Logger = msg => println(msg)
extension [L, R](either: Either[L, R])
def logError(context: String)(using logger: Logger): Either[L, R] =
either.left.foreach(err => logger.error(s"[$context] Error: $err"))
eitherNow logging is a one-liner per operation:
given Logger = Logger.console
val invoice = for
user <- fetchUser(userId).logError("fetchUser")
items <- fetchVaultItems(user).logError("fetchVaultItems")
identifier <- invoiceIdentifier(user).logError("invoiceIdentifier")
invoice <- Right(fullInvoice(description, items))
yield invoiceNote: the .logError method takes a context: String, but also a using logger: Logger. In the call-site, I only pass the context. When a function expects a parameter with using, you can establish an instance of that class by calling given MyType = someInstance. Subsequent calls (in the same, or a lower level scope) will automatically use the given instance.
Adding more stuff
I decided, I want to make my program longer and (fake) send an email:
def sendMail(invoice: String): Try[Unit] =
Try:
println(s"Sending email with invoice:\n$invoice")
if invoice.contains("Bob") then
throw new RuntimeException("Email service failed")
println("Email sent successfully")sendMail can fail, but instead of an Either, it returns a Try. A try is a pseudo-monad, that opens a try/catch block internally and hands you some result, or the caught exception.
The new program looks like this:
val result = for
user <- fetchUser("123").logError("fetchUser")
items <- fetchVaultItems(user).logError("fetchVaultItems")
identifier <- invoiceIdentifier(user).logError("invoiceIdentifier")
invoice <- Right(fullInvoice(identifier, items))
emailResult <- sendMail(invoice).toEither.logError("sendMail")
yield "Invoice process completed"
println:
result match
case Left(err) => println(s"Operation failed with error: $err")
case Right(msg) => println(msg)Some will be angry about using println with colon syntax ¯\_(ツ)_/¯
Explanation:
As previously with fullInvoice, I need to convert sendMails result to an Either. Luckily it's trivial as Try has an inbuilt method .toEither. Afterwards I can still call my extension method logError that I had added to the Either type.
In the end, I print the result of the operation. In the yield, I return a String, but it gets wrapped into the Monad type that we used throughout the entire chain - an Either. So in order to evaluate the content I have to unwrap the result somehow. It can be either a Left containing some error, or a Right[String] containing the success message.
What's the Trick?
Why does Either work so elegantly while union types leave us with caveman code?
Either is a value, not just a type. When you have an Either[E, A], you have an actual object with methods like map, flatMap, fold, and foreach. Union types are purely a compile-time concept – at runtime, you just have the raw value with no additional methods.
This means Either can be extended. Our logError extension method works because it receives an Either object, calls .left.foreach() on it, and returns the same Either unchanged. You can't extend union types the same way because there's no common wrapper to attach methods to.
For comprehensions are syntactic sugar. When you write:
for
user <- fetchUser("456")
items <- fetchVaultItems(user)
yield fullInvoice(user, items)The compiler transforms it into:
fetchUser("456").flatMap { user =>
fetchVaultItems(user).map { items =>
fullInvoice(user, items)
}
}Either's flatMap short-circuits on Left: If fetchUser returns a Left, the entire chain stops and returns that Left. No manual error checking needed.
Union types don't have flatMap. They're just types, not values with methods. That's why we end up with isInstanceOf checks or nested match expressions.
Pitfalls of Either

The Silent Failure Anti-Pattern
For comprehensions are so elegant that it's tempting to write:
val result = for
user <- fetchUser(userId)
items <- fetchVaultItems(user)
invoice <- generateInvoice(user, items)
_ <- sendEmail(invoice)
yield "Success!"
println(result.getOrElse("Something went wrong"))Bad!!!
When this fails in production at 3 AM, you'll have no idea which step failed or why. The error vanishes into the void, replaced by a useless "Something went wrong" message. Do not do this!
Make sure you log each error along the way, using the extension method shown above, or coming up with your own way of dealing with it.
Type Gymnastics
If you have a function that goes through a series of fallible steps, the result may contain any of the errors created along the way. Therefore, you are forced to enumerate all possible error types:
type ProcessError = UserFetchError | VaultFetchError | UserDescriptionError | Throwable
def process(userId: String): Either[ProcessError, String] =
for
user <- fetchUser(userId).logError("fetchUser")
items <- fetchVaultItems(user).logError("fetchVaultItems")
identifier <- invoiceIdentifier(user).logError("invoiceIdentifier")
invoice <- Right(fullInvoice(identifier, items))
emailResult <- sendMail(invoice).toEither.logError("sendMail")
yield "Invoice process completed"
val result = process()Here, I created a type alias ProcessError that is UserFetchError | VaultFetchError | UserDescriptionError | Throwable (the latter comes from sendMails Try
In fact those are all the errors that can be created as part of our chain and we must incorporate them all in our "end result". Perhaps interestingly, this shows that you can use Union types inside of an Either!
Failing to create the type signatures correctly will lead to one of two things: The program refuses to compile; or you end up with an Any signature, which is something to avoid. So be prepared for road bumps when using this pattern with large lists of operations, some of which may have been written by third parties.
When to Use Which?
Use Either When:
- Chaining operations - Multiple fallible steps in sequence
- Cross-cutting concerns - Logging, metrics, retries via extension methods
- Composable pipelines - Building complex workflows from simple parts
- Aesthetics - You want your code to be an elegant piece of art
Use Union Types When:
- Pattern matching at the call site - You immediately match on the result
- Single-operation results - No chaining needed
- Keeping it simple - Fewer pitfalls for newcomers
- No monadic syntax sugar - If your programming language does not explicitly support Either as a feature, but has Union types: Probably best to stick with Union types.
Final Notes
I created this article to answer a question that I had for a long time, namely "Why bother with Either"? I am still relatively new when it comes to Scala (1 year hobby experience), so the code that I shared may not be the best option for your medical or financial applications. I am not interested in effect systems at the moment (truly side-effect free composition), but I imagine the transition from sequential if-checks to monadic for-comprehensions is a step in that direction.