Algebraic Data Types in Scala
Learn algebraic data types in Scala for Python developers: what they are, how they work, and how to use them in practice.
Focus: algebraic data types in scala
If you've ever tried to represent "this value is either a success or a failure" in Python, you've probably ended up with tuples, dicts, or class hierarchies — and a headache when you tried to handle every case. In Scala, algebraic data types (ADTs) give you a precise, exhaustive, and type-safe way to model complex data. This lesson explains what ADTs are, how they work step by step, and how to use them idiomatically — so you can finally replace your ad-hoc unions with clean, expressive code.
The problem this lesson solves
Python's dynamic typing is flexible, but it often puts the burden on you to remember every possible shape of your data. Consider representing a payment result: it could be a success with a transaction ID, a failure with an error message, or a pending state. In Python, you might write:
# Python: ambiguous representation
def process_payment(payment_id):
if payment_id:
return ("success", payment_id)
else:
return ("failure", "card declined")
The problem? Nothing stops you from accidentally treating a failure as a success. You have to rely on string comparisons and runtime checks — and if you miss a case, your code silently misbehaves. In Scala, algebraic data types solve this by making all possible states explicit and forcing you to handle every one at compile time. That's the pain this lesson removes: you no longer have to remember what your data can be — the compiler does it for you.
Core concept / mental model
Think of an algebraic data type as a formal way to say "this thing is exactly one of these predefined shapes". The name "algebraic" comes from two operations you already know from math:
- Sum type (OR): the value is one of several alternatives — like
A | Bin type theory. - Product type (AND): the value has all of several parts — like a
(firstName, lastName)tuple.
In Scala 3, you define ADTs using enum (replacing the older sealed trait + case class approach). Here's a mental picture:
PaymentResult (sum type)
/ | \
Success Failure Pending
(product: (product: (no data)
id) message)
PaymentResultis a sum type because it can be exactly one of those three cases.Success(id)andFailure(message)are product types because they bundle multiple values.- Combined, you get an ADT — a type that precisely describes your domain.
Pro tip: Once you've defined an ADT, the compiler can check that you've handled every case. That's the superpower — exhaustive pattern matching — which Python's runtime checks can't offer.
How it works step by step
Getting ADTs working in Scala 3 follows a logical sequence. Here's how you build one from scratch:
- Define the sealed type — use the
enumkeyword (in Scala 3) orsealed trait(in Scala 2). This establishes the closed set of possible cases. - List the cases — each case is either a simple singleton (like
Pending) or carries data (likeSuccess(id: String)). - Add methods or extensions — you can write functions that pattern-match on the cases, and the compiler will warn if you miss one.
- Use it everywhere — create values of the ADT and write logic that consumes them safely.
The cause-effect is simple: define the shape once, and every use site gets compile-time guarantees.
Hands-on walkthrough
Let's build a complete example. Start with a Scala 3 project (e.g., using scala-cli or a build tool like sbt), then paste the following into a .scala file.
Step 1: Define the ADT
// PaymentResult.scala
enum PaymentResult:
case Success(transactionId: String, amount: Double)
case Failure(reason: String)
case Pending
Step 2: Write a function that handles every case
def describe(result: PaymentResult): String =
result match
case Success(id, amount) => s"Paid $amount (txn: $id)"
case Failure(reason) => s"Failed: $reason"
case Pending => "Waiting..."
Step 3: Test it
@main def demo(): Unit =
val paid = PaymentResult.Success("TXN-001", 99.95)
println(describe(paid)) // Paid 99.95 (txn: TXN-001)
println(describe(PaymentResult.Failure("card declined")))
println(describe(PaymentResult.Pending))
Expected output:
Paid 99.95 (txn: TXN-001)
Failed: card declined
Waiting...
That's it — you've just used an algebraic data type! The moment you try to add a new case (e.g., Refunded), the compiler will tell you exactly which match expressions need to be updated. That's a huge win for refactoring.
Python equivalent for comparison:
from dataclasses import dataclass
from typing import Union
@dataclass
class Success:
transaction_id: str
amount: float
@dataclass
class Failure:
reason: str
class Pending:
pass
PaymentResult = Union[Success, Failure, Pending]
Until you use patterns like match with case (Python 3.10+) and type guards, Python's version remains less safe — nothing stops you from treating a Failure as a Success at runtime.
Compare options / when to choose what
In Scala, you can build ADTs two ways. Here's a quick comparison:
| Feature | enum (Scala 3) |
sealed trait + case class (Scala 2) |
|---|---|---|
| Syntax | Concise enum PaymentResult: |
Verbose sealed trait + separate case class definitions |
| Pattern matching | Same | Same |
| Extensibility outside file | Not allowed (sealed) | Not allowed (sealed) |
| Recommended for new code | Yes | Only for Scala 2 compatibility |
| Boilerplate | Minimal | More (must repeat extends keyword) |
When to choose what: Always prefer
enumin Scala 3. If you're maintaining an existing Scala 2 codebase, you'll encountersealed trait, but you can migrate gradually.
Another comparison: case classes vs regular classes. If you need ADTs, always use case class or enum — they auto-generate equals, hashCode, and copy methods, which are essential for value semantics.
Troubleshooting & edge cases
“I get a non-exhaustive match warning” — This means you forgot to handle a case. Add the missing case or add a case _ fallback. The warning is your friend.
“I can't extend my sealed trait outside the file” — Sealed types must have all cases in the same file. If you need extensibility, don't use an ADT — use an open trait or an interface. That's a design choice, not a bug.
“My match expression doesn't compile” — Common cause: pattern syntax is wrong. For a case class with fields, always write case Success(id, amount) => ... — don't put parentheses around the whole case incorrectly.
“I have a case with no data” — Use a simple object-like case like case Pending (or case object Pending in Scala 2). Don't force empty parentheses — it's unnecessary and less idiomatic.
“I need a recursive ADT” — That's fine. For example, a binary tree case can reference itself:
enum Tree:
case Leaf(value: Int)
case Node(left: Tree, right: Tree)
Just make sure you eventually reach a non-recursive case.
What you learned & what's next
You now understand algebraic data types in Scala: what sum and product types are, how to define them with enum or sealed trait, how to pattern-match exhaustively, and when to prefer one syntax over another. You've also seen how they fix Python's representational ambiguity with compile-time safety.
Next in the track: You're ready to dive into pattern matching in depth — using ADTs with guards, nested patterns, and binding variables. That's the next step in making your Scala code both expressive and bulletproof. Keep practicing by defining your own ADTs for a domain you know well — like a shape (Circle, Rectangle) or a network response (Success, Error). The more you use them, the more natural they become.
Practice recap
Now try this: create an ADT for a shape (circle, rectangle, triangle) with a method to compute area. Use pattern matching to pick the right formula. Then run your code with a few test shapes — the compiler will make sure you've covered all three cases.
Common mistakes
- Treating an ADT like a traditional class hierarchy — forgetting that cases are sealed and cannot be added from outside the file, which is actually a feature.
- Using plain
classinstead ofcase classin Scala 2 — you loseequals,hashCode, and pattern-matching support. - Forgetting to handle all cases in a
match— you'll get a non-exhaustive warning; always add a fallback or handle every case explicitly. - Mixing Python habits like using string constants or dicts instead of an ADT — that defeats the type safety you get in Scala.
Variations
- Use
sealed trait+case classin Scala 2 instead ofenumin Scala 3 — same concept, different syntax. - Add recursive ADTs for tree or list structures — common for domain models.
- Use ADTs with generic types (e.g.,
Option[T],Either[E, A]) — they're built-in and everywhere.
Real-world use cases
- Modeling payment status in an e-commerce system — success, failure, pending — with exhaustive matching for each state.
- Representing a REST API response as either
Success(data)orError(status, message)to avoid null checks. - Defining a shape hierarchy for a graphics library — circle, rectangle, triangle — with a
drawmethod that pattern-matches.
Key takeaways
- An algebraic data type combines sum types (OR) and product types (AND) to express all possible data shapes precisely.
- In Scala 3,
enumis the idiomatic way to define ADTs; usesealed traitonly for Scala 2 compatibility. - Pattern matching on an ADT is exhaustive — the compiler forces you to handle every case, preventing runtime surprises.
- ADTs reduce boilerplate compared to Python's dynamic unions, giving you value equality and copy methods automatically.
- Recursive ADTs are powerful for modeling trees and nested structures, and they work seamlessly with pattern matching.
Keep learning
Related tutorials, quizzes, and articles for this topic.
Discussion
Questions, corrections, and tips help everyone reading this page.
0 comments
Add a comment
No comments yet — start the thread.