Skip to main content

Welcome to a Grasp lesson

Create a free account to keep studying and create your own study path.

Lesson 3

Scala Case Classes & Pattern Matching for Data Records

Recommended resources

Hello! Welcome back.

In our last lesson, we covered the basics of Scala syntax, translating familiar concepts from Python like variables, functions, and collections. You learned about Scala's preference for immutability and its static type system.

Today, we'll build on that by learning how to define the shape of our data. The learning outcome is to model type-safe data records using Scala case classes and destructure them with pattern matching. In any data pipeline, data isn't just a raw stream of bytes; it has a structure. In Scala, case class is the primary tool for defining this structure in a way that is both concise and incredibly robust. We will then explore pattern matching, the idiomatic Scala way to inspect and react to this structured data, which is far more powerful than a simple if/else or switch statement.

1. Modeling Immutable Records with case class

In Python, you might use a dict, a tuple, or more formally, a dataclass to represent a record. In Go, you would use a struct. Scala's equivalent is the case class. It's designed specifically for holding immutable data and comes with a lot of helpful features generated automatically by the compiler.

Domain Modeling — Tools | Scala 3 Book

To start, let's read the official documentation on case class. This will give you a concise overview of their purpose and the features the compiler provides automatically.

Read the section titled 'Case classes'. Pay attention to how they enforce immutability (val fields by default) and the list of auto-generated methods like copy, equals, and unapply (which we'll see is the magic behind pattern matching).

A case class gives you:

  • Immutable fields by default.
  • An apply method, so you can write Person("Alice") instead of new Person("Alice").
  • Sensible toString, equals, and hashCode methods out of the box.
  • A copy method, which is the standard way to create a new instance based on an old one but with some fields changed. This is fundamental to working with immutable data.

Hands-on Practice:

Create a new file in src/main/scala/ called DataModels.scala and add the following code.

// Define a case class to represent a crypto trade.
// The fields `pair`, `amount`, and `price` are public and immutable.
case class CryptoTrade(pair: String, amount: Double, price: Double)

@main def modelPractice(): Unit = {
  val trade1 = CryptoTrade("BTC/USD", 0.5, 62000.0)

  // 1. The auto-generated toString is clean and useful for logging.
  println(s"New trade: $trade1")

  // 2. Let's create a new trade record by 'modifying' the price of the first one.
  // This creates a NEW object, trade1 is untouched.
  val trade2 = trade1.copy(price = 62500.0)
  println(s"Updated trade: $trade2")
  println(s"Original trade is unchanged: $trade1")

  // 3. Trying to mutate a field will result in a compile-time error.
  // Uncomment the line below to see Metals flag it in Helix.
  // trade1.amount = 0.6 // error: reassignment to val
}

This immutability-by-default and the copy pattern are essential for the kinds of distributed systems you'll build with Apache Beam, as it prevents unpredictable side effects when data is processed in parallel.


2. The Blueprint for Data: Algebraic Data Types (ADTs)

Now, let's elevate the concept. A case class is a Product Type: it combines multiple types into one (e.g., String and Double and Double). But what if your data can be one of several distinct shapes? For this, we use a Sum Type.

In Scala, a sum type is typically implemented with a sealed trait and a set of case class or case object implementations. The sealed keyword is a contract with the compiler, telling it that all possible subtypes are defined in the current file. This enables the compiler to check if your code handles all possible cases, preventing entire classes of bugs.

This combination of sum and product types is called an Algebraic Data Type (ADT). It is the foundation of functional data modeling and is perfect for defining the precise data contracts in a pipeline.

Algebraic Data Types (ADT) in Scala | Rock the JVM

The following video from Rock the JVM provides an excellent explanation of ADTs. It clearly distinguishes between Sum and Product types and, most importantly, explains why this model is so effective.

Watch the following segments: Sum Types (00:30 - 04:27): Understand how a sealed trait represents an 'OR' relationship. Product Types (04:27 - 06:22): See how a case class represents an 'AND' relationship. Hybrid Types (06:22 - 07:40): See how they combine. The Big Advantage (07:40 - 12:06): This is the key part. Pay close attention to the argument that ADTs make illegal states unrepresentable. This is a massive benefit for data integrity.

As an engineer with a background in physics and robust data systems, you can think of this as defining a constrained state space for your data at compile time. Instead of writing runtime validation code to check if a status string is one of "pending", "success", or "failure", you define it as a sum type, and the compiler guarantees no other state can exist.


3. Deconstructing Data with Pattern Matching

You've now defined your data models. How do you work with them? This is where pattern matching shines. It's a match expression that allows you to check a value against a series of patterns and execute code for the first pattern that matches.

With case class and sealed trait, it allows you to:

  1. Deconstruct a case class into its constituent fields.
  2. Exhaustively match against all subtypes of a sealed trait.
Scala Case Classes and Pattern Matching Example
This image shows basic pattern matching on a `Book` case class. Notice how the `case Book(...)` syntax allows direct access to the `title`, `author`, and `price` fields.

Let's look at some more powerful techniques.

Pattern Matching Tricks in Scala | Rock the JVM

This video, also from Rock the JVM, demonstrates some practical and powerful pattern matching techniques you'll use frequently.

Watch these key tricks: Deconstruction of case classes (01:54 - 03:05): The fundamental use case. Name binding (13:18 - 16:21): Using the @ symbol to give a name to a matched pattern while also deconstructing it. This is very useful. Conditional clauses (guards) (16:21 - 18:20): Using if inside a case to add extra logic to your match.

Hands-on Practice:

Let's combine everything. Add this ADT and the processing function to your DataModels.scala file. This models a common scenario in data pipelines: handling different types of events.

// A Sum Type for different user actions
sealed trait UserAction

// Product Types representing specific actions
case class PageView(url: String, userId: String) extends UserAction
case class AddToCart(productId: String, quantity: Int, userId: String) extends UserAction
case object LoginSuccess extends UserAction // A case object for events with no data

// A function that uses pattern matching to process actions
def processAction(action: UserAction): String = {
  action match {
    // Deconstruct PageView and bind its fields to variables
    case PageView(url, user) =>
      s"User '$user' viewed page '$url'."

    // Deconstruct AddToCart and add a guard
    case AddToCart(prodId, qty, user) if qty > 0 =>
      s"User '$user' added $qty of product '$prodId' to cart."

    // Use name binding to capture the whole object, while also matching its type
    case add @ AddToCart(_, _, _) =>
      s"Ignoring invalid AddToCart event with non-positive quantity: $add"
    
    // Match the simple case object
    case LoginSuccess =>
      "User logged in successfully."
  }
}

// In your @main method:
@main def modelPractice(): Unit = {
  // ... (previous code) ...
  println("\n--- Pattern Matching Practice ---")
  
  val actions: List[UserAction] = List(
    PageView("/products/123", "user-a"),
    AddToCart("prod-xyz", 2, "user-b"),
    LoginSuccess,
    AddToCart("prod-abc", 0, "user-c") // This will match the guarded case
  )

  actions.foreach(action => println(processAction(action)))

  // Notice in Helix: if you comment out one of the `case` lines, 
  // the compiler will warn you that the match is not exhaustive!
}

Run this file (sbt "runMain modelPractice"). You'll see how the processAction function correctly handles each different data shape.


Conclusion

In this lesson, you've learned the cornerstone of data modeling in Scala. This approach is central to writing the transformation logic required by the job description you're targeting.

Key Takeaways:

  • Product Types: Use case class to model immutable data records that are composed of several fields (an "AND" relationship).
  • Sum Types: Use a sealed trait with case class/object children to model data that can be one of several distinct kinds (an "OR" relationship). This pairing is an Algebraic Data Type (ADT).
  • Pattern Matching: This is the primary mechanism for working with ADTs. It allows you to safely deconstruct your data models and handle all possible variations, with the compiler helping you ensure you haven't missed a case.
  • Immutability: The copy method on case class is the idiomatic way to produce an "updated" version of a record without mutation.

In our next lesson, we will apply common transformations on the Scala Collections API (map, flatMap, filter, fold). You'll see how the data models we defined today are transformed within collections using functional methods that are direct precursors to the core transforms in Apache Beam.