Scalable.FYI
ServicesBlogAbout UsCONTACTS

Why Use Scala / Extractors & Custom Pattern Matching

Engineering Team · 4 min read

2026-08-24

Why Use Scala / Extractors & Custom Pattern Matching

Introduction

In every pattern-matching example we saw until now in our posts, i.e. this one, it always matched against a shape the compiler already understood: a sealed hierarchy you defined yourself, as covered in algebraic data types. Real systems, however, don't always hand you data neatly aligned with your expectations. Lots of times you get a raw string (we know, we know, what an abomination !), or an instance of a class another team, or some third-party library, already wrote, and you don't get to add a constructor to it.

Say an upstream system hands you order references as plain strings, like "BUY-AAPL-100": an order type, a ticker, and a quantity, all packed into one String with no structure a compiler can typecheck. You could write a parsing function and call it before every match, but Scala lets you go one step further: teach the match itself how to take that string apart, by writing an extractor, a plain unapply method with no requirement that it lives anywhere near the type it's deconstructing.

Python: structural, but only for your own classes

Python's match statement (the one covered a few posts back) does support destructuring, but only for shapes it can already introspect: positional class patterns rely on a __match_args__ tuple that a class defines about itself. There is no separate hook a third party can register to teach match how to take apart a type, string or otherwise, that it doesn't control.

Python
class BuyOrder:
    __match_args__ = ("ticker", "quantity")
    def __init__(self, ticker: str, quantity: int):
        self.ticker = ticker
        self.quantity = quantity

# in some distant codebase, we get an order like this
order = BuyOrder("AAPL", 100)

# This destructuring works, but only because BuyOrder declared
# __match_args__ itself.
match order:
    case BuyOrder(ticker, quantity):
        print(f"buying {quantity} {ticker}")

# A raw order reference string has no __match_args__ to hook into, and
# str is a builtin you can't retroactively add one to. The parsing has
# to happen BEFORE the match, as an ordinary function call:
def parse_ref(ref: str) -> tuple[str, str, int] | None:
    import re
    m = re.match(r"^(BUY|SELL)-([A-Z]+)-(\d+)$", ref)
    return (m[1], m[2], int(m[3])) if m else None

match parse_ref("BUY-AAPL-100"):
    case (side, ticker, qty):
        print(f"{side} {qty} {ticker}")
    case None:
        print("not a valid order reference")

TypeScript: a type guard per type, written by hand

TypeScript has no match statement at all, just if/switch plus narrowing. The idiomatic way to teach the compiler how to recognize a shape is a user-defined type guard, a function returning an x is Foo predicate. It plays the same role as an extractor, minus the part where it actually extracts anything: a type guard only narrows a type, the caller still has to pull the pieces back out by hand afterward. To even have something worth narrowing into here, the example below reaches for the same branded-type workaround covered in opaque types.

TypeScript
type OrderRef = { side: "BUY" | "SELL"; ticker: string; quantity: number };

// The same branded-type trick from the opaque types post, used here to
// give the type guard something to actually narrow into.
type ValidRefString = string & { readonly __brand: "OrderRef" };

function isOrderRef(ref: string): ref is ValidRefString {
  return /^(BUY|SELL)-[A-Z]+-\d+$/.test(ref);
}

function parseOrderRef(ref: ValidRefString): OrderRef {
  const m = ref.match(/^(BUY|SELL)-([A-Z]+)-(\d+)$/)!;
  return { side: m[1] as "BUY" | "SELL", ticker: m[2], quantity: Number(m[3]) };
}

const raw = "BUY-AAPL-100";
if (isOrderRef(raw)) {
  // isOrderRef narrowed raw's TYPE to ValidRefString, but bound none of
  // the actual pieces. Extracting them is a second, separately-written
  // function, called again here, that has to be kept in sync with the
  // regex above by hand.
  const order = parseOrderRef(raw);
  console.log(order.ticker);
}

Nothing connects isOrderRef and parseOrderRef other than a developer remembering to keep their regexes identical. Get them out of sync (say, one accepts lowercase tickers and the other doesn't) and the type guard passes while the parser silently returns undefined, or the reverse.

Java: record patterns, only for the ones you own

Java 21's pattern matching for switch can deconstruct records field by field, which covers the BuyOrder-as-a-class case well. But that deconstruction is derived automatically from the record's own canonical constructor, there is no equivalent of unapply a third party could write to teach switch how to deconstruct a type, such as String, that isn't a record you authored.

Java
record BuyOrder(String ticker, int quantity) {}

static String describe(Object order) {
    return switch (order) {
        // Deconstructs BuyOrder because it's a record: the compiler
        // already knows its exact field layout from the declaration.
        case BuyOrder(String ticker, int quantity) -> quantity + " " + ticker;
        default -> "unknown";
    };
}

// A raw order reference string has no record to deconstruct against.
// Same as Python, parsing happens separately, before the switch:
static String[] parseRef(String ref) {
    var m = java.util.regex.Pattern
        .compile("^(BUY|SELL)-([A-Z]+)-(\\d+)$")
        .matcher(ref);
    return m.matches() ? new String[]{m.group(1), m.group(2), m.group(3)} : null;
}

Scala: match anything you can write an unapply for

In Scala, an extractor is an unapply method defined inside an object, returning an Option of a tuple. Nothing ties it to the type being matched. It can live anywhere, and it can deconstruct a type it does not own, String included, which is exactly the order reference from every example above:

Scala
object OrderRef {
  private val pattern = """^(BUY|SELL)-([A-Z]+)-(\d+)$""".r

  def unapply(ref: String): Option[(String, String, Int)] = ref match {
    case pattern(side, ticker, qty) => Some((side, ticker, qty.toInt))
    case _ => None
  }
}

// The extractor plugs directly into match: no separate parse, then check, steps,
// and no second function to keep in sync with a regex written elsewhere.
def describe(ref: String): String = ref match {
  case OrderRef(action, ticker, qty)  => s"${action.toLowerCase}ing $qty $ticker"
  case _                              => "not a valid order reference"
}

describe("BUY-AAPL-100")  // "buying 100 AAPL"
describe("garbage")       // "not a valid order reference"

// Because there's nothing special about unapply methods, you can also
// provide guards as you are already used to
def describe2(ref: String): String = ref match {
  case OrderRef(a, t, q) if q > 0 => s"${a.toLowerCase}ing $q $t"
  case OrderRef(a, t, _)          => s"cannot ${a.toLowerCase} zero $t"
  case _                          => "not a valid order reference"
}

describe2("BUY-AAPL-100")  // "buying 100 AAPL"
describe2("SELL-MSFT-0")   // "cannot sell zero MSFT"

This is the same mechanism, not a special case, behind patterns you have already met. Scala's own Regex objects use exactly this unapply hook (that's what made pattern(side, ticker, qty) work above), and so does destructuring a List as head :: tail.

That :: is an object with a funny name and an extractor. It reads like a real operator only because Scala writes it infix rather than as ::(head, tail).

The bottom line: Scala has no fixed list of "types allowed to be pattern-matched". You can write an unapply for any type, whether or not you own it, whether it comes from the JDK or from a library whose source you cannot touch.

One definition, not one per call site

Every non-Scala example above needed the extraction logic written as its own function, called separately from the branching logic that consumes it, with nothing enforcing that the two stay consistent. That's a specific failure mode for an AI agent: asked to add a new place that branches on an order reference, the fastest path is often to inline a fresh regex or a fresh parsing helper on the spot, rather than go find the existing one, especially when the existing one isn't sitting right next to a match or switch the way an extractor is.

With OrderRef defined once, every future match against an order reference reuses the same unapply, textually right there in the case line. An agent extending the branching logic has no reason to reinvent the parsing, and no way to accidentally drift the two out of sync, because there's only one definition to call, not a pair of functions to keep aligned by hand.