Scalable.FYI
ServicesBlogAbout UsCONTACTS

Why Use Scala / Product Types and Tuples

Engineering Team · 9 min read

2026-09-10

Why Use Scala / Product Types and Tuples

Introduction

When we modeled a Bank's payment status, back in the algebraic data types post, we achieved that via a specific ADT called the sum type: a value that comes from a fixed set of alternatives. Back then, we mentioned briefly that it was just half of the picture: today we will visit the so-called product type, a value that carries a multiple types, all at once.

Every programming language has its own way to express product types: a class with N fields is the most obvious example. The other, equally obvious example, are tuples: an anonymous pairing of two or more types.

But because they're anonymous, the interesting question we're asking ourselves in today's blog post, is not whether you can express, for example, "an Int and a BigDecimal" but, rather: what can you do, if the pairing is localized and lives for just few lines, and what happens in case somebody, in a distant future, decides to add an extra item to the pair.

We will consider a very simple example: walk a list of payments, and report how many were approved and what they added up to. Two values out of one call. It sounds too small to be interesting and yet, it will be just enough to see how the four language can differ.

Python: one of the most concise syntax

Python got the ergonomics right long before anyone else did, and it is still the nicest of the four to look at.

Python
def summarize(payments):
    accepted = [p for p in payments if p.approved]
    return len(accepted), sum(p.amount for p in accepted)

count, total = summarize(payments)

As you just saw, there is no need for a wrapper class, no ceremony of any kind, and the call site reads like what it does, destructuring the return type of the function into two variables. The trouble begins when that tuple escapes the scope of the function because, although you could write an explicit tuple[int, float] return type, that information is not enforced by the compiler and the interpreter never reads it. Therefore if, in some distant future, somebody other developer decides to add a third value later, every existing caller is now wrong.

Python
def summarize(payments) -> tuple[int, float, list[Payment]]:
    accepted = [p for p in payments if p.approved]
    rejected = [p for p in payments if not p.approved]
    return len(accepted), sum(p.amount for p in accepted), rejected

count, total = summarize(payments)
# ValueError: too many values to unpack (expected 2)

And the problem, as we highlighted already in previous posts, is that the error lands at runtime, in whichever call site runs first. You must manually check all call sites and make sure they're all updated accordingly or, if you're particularly unlucky, and forgot a reference in a code path that is rarely taken, and you'll have an annoying production incident to resolve.

The usual fix is a NamedTuple, which gives you fields names, but it helps less than you would expect. Every check must be opt'ed-in. Run a type checker over the previous example, and it does report the bad unpacking from the tuple[int, float, list[Payment]] annotation alone, without NamedTuple anywhere. Skip it, as that example did, and you get the ValueError instead. What the names buys you on top is field access the checker can verify, so summary.conut gets reported rather than surfacing as an AttributeError. What none of it changes is what the value actually is. A NamedTuple is still a tuple, and every tuple behaviour comes with it.

Python
from typing import NamedTuple

class Summary(NamedTuple):
    count: int
    total: float

class Window(NamedTuple):
    start: int
    end: int

Summary(1, 20.0) == (1, 20.0)        # True
Summary(1, 20.0) == Window(1, 20.0)  # True
Summary(1, 20.0) + ("EUR",)          # (1, 20.0, 'EUR'), a plain tuple again
Summary(1, 20.0).count(1)            # TypeError: 'int' object is not callable

A summary type comparing equal to an unrelated window type is not a thought experiment and, if you're not careful, you might end up comparing unrelated types without even noticing it. The last line of the snippet showcases another problem you may encounter, if you‘re not careful enough. Because Summary is a subclass of tuple, it inherits tuple's own .count() and .index() methods, and a field named count takes that name over. Summary(1, 20.0).count is the integer 1, so calling with a parameter will raise a TypeError.

TypeScript: type-checked tuple types, but elements easy to forget

TypeScript has actual tuple types, checked at compile time, with optional element labels since 4.0. Out of the three languages we compare against this is, without doubt, the strongest showing.

TypeScript
function summarize(payments: Payment[]): [count: number, total: number] {
  const accepted = payments.filter((p) => p.approved);
  return [accepted.length, accepted.reduce((sum, p) => sum + p.amount, 0)];
}

const [count, total] = summarize(payments);

However, there are still some aspects where the language falls short. The first is that those labels are comments with nicer syntax. Return [total, count] by mistake and both are numbers, therefore the compiler will happily let it pass.

The second is that a tuple type is still an array type underneath, so every array method comes with it.

TypeScript
const summary = summarize(payments);

summary.push(99);                           // no error, under --strict
const doubled = summary.map((n) => n * 2);  // number[], the arity is gone

The third is that you have to ask for a tuple by name every time, and forgetting is the default outcome.

TypeScript
const pair = [1, "two"];            // (string | number)[]
const pinned = [1, "two"] as const; // readonly [1, "two"]

And then, there is the change we actually care about: what happens by adding a third return value to a tuple?

TypeScript
function summarize(payments: Payment[]): [count: number, total: number, rejected: Payment[]] {
  const accepted = payments.filter((p) => p.approved);
  const rejected = payments.filter((p) => !p.approved);
  return [accepted.length, accepted.reduce((sum, p) => sum + p.amount, 0), rejected];
}

const [count, total] = summarize(payments);
// compiles. Every caller keeps reading the first two and drops the third.

Destructuring fewer elements than the tuple holds is legal TypeScript, so the compiler has nothing to say. I find this the worst of the four outcomes, because Python at least raises something (in the worst possible time, but still it raises it). Here the change is invisible at every layer: it builds, it runs, and the new value simply disappears out of thin air.

Java: no tuple at all

Java has no concept at all of tuples. There are few ways get around it, but they feel more like a hack.

The cheapest is Map.Entry pressed into service as a pair. It typechecks, but the readability is ... questionable, to say the least.

Java
static Map.Entry<Integer, BigDecimal> summarize(List<Payment> payments) {
    List<Payment> accepted = payments.stream().filter(Payment::approved).toList();
    return Map.entry(
        accepted.size(),
        accepted.stream().map(Payment::amount).reduce(BigDecimal.ZERO, BigDecimal::add));
}

var pair = summarize(payments);
pair.getKey();    // the count
pair.getValue();  // the total

If you run on Java 16 or newer, using a record is probably the better way to go.

Java
record Summary(int count, BigDecimal total) {}

static Summary summarize(List<Payment> payments) {
    List<Payment> accepted = payments.stream().filter(Payment::approved).toList();
    return new Summary(
        accepted.size(),
        accepted.stream().map(Payment::amount).reduce(BigDecimal.ZERO, BigDecimal::add));
}

Records are good, and naming a shape is often better than leaving it anonymous. The problem is that Java forces you to name it, always, whether the shape needs a name or not, including for a pairing that lives three lines inside a stream pipeline. There is no lighter option to fall back on.

Fortunately, Java 21 eased a bit the syntax via record patterns.

Java
if (summarize(payments) instanceof Summary(int count, BigDecimal total)) {
    report(count, total);
}

That works inside an instanceof or a switch, but that's all. There is no var (count, total) = summarize(payments) destructuring, so a plain local binding still means two accessor calls.

Now run our third value through it. Adding rejected to the record breaks the constructor call and every record pattern that destructured it, all at compile time.

Java
record Summary(int count, BigDecimal total, List<Payment> rejected) {}

// error: constructor Summary in record Summary cannot be applied to given types;
//   required: int,BigDecimal,List<Payment>
//   found:    int,BigDecimal
return new Summary(
    accepted.size(),
    accepted.stream().map(Payment::amount).reduce(BigDecimal.ZERO, BigDecimal::add));

// error: incorrect number of nested patterns
//   required: int,BigDecimal,List<Payment>
//   found: int,BigDecimal
if (summarize(payments) instanceof Summary(int count, BigDecimal total)) { ... }

On this one change, Java does better a better job than Python and TypeScript both. But this "improvement" is purely accidental, and due to the fact that the language doesn't have a native support for tuples.

Scala: lean syntax, supercharged

Tuples in Scala have a lean syntax, pretty much akin to what we saw in the Python example:
Scala
def summarize(payments: List[Payment]): (Int, BigDecimal) = {
  val accepted = payments.filter(_.approved)
  (accepted.size, accepted.map(_.amount).sum)
}

val (count, total) = summarize(payments) // this is a pattern-match destructuring <3

Here, (Int, BigDecimal) is nothing more than syntactic sugar for Tuple2[Int, BigDecimal]; the return type states both the arity and element types, in a compact way. What is more interesting, though, is the line we added a comment on. In fact, Scala can perform a pattern-match destructuring on the left of any val, not only inside a match.

Scala
summarize(payments) match {
  case (count, total) => // do something with count and total
}

val (count, total) = summarize(payments)
// do something with count and total

In the pattern matching post we only ever showed the first form. This second one has the same machinery, and it is how tuples often get taken apart in day to day code.

As a consequence of this, whenever we add an extra type to the tuple, we don't get a runtime failure as it is the case in Python, nor we silently lose an element, just like in TypeScript: the compiler kicks in immediately, at each call site, failing the build until everything gets resolved.

Scala
def summarize(payments: List[Payment]): (Int, BigDecimal, List[Payment]) = {
  val (accepted, rejected) = payments.partition(_.approved)
  (accepted.size, accepted.map(_.amount).sum, rejected)
}

val (count, total) = summarize(payments)
// error: pattern's type (Any, Any) does not match the right hand side
//        expression's type (Int, BigDecimal, List[Payment])

And because, once more, destructuring is pattern matching, that same shape works anywhere a pattern is allowed, without the need for you to learn another syntax.

Scala
def describe(t: (Int, BigDecimal)): String = t match {
  case (n, _)   if n == 0 => "nothing went through"
  case (1, value)         => s"one payment of $value"
  case (n, sum)           => s"$n payments, $sum total"
}

// in a lambda, on the pairs groupBy hands back
payments.groupBy(_.approved).map((approved, group) => (approved, group.size))
// Map(false -> 1, true -> 1)

// and zip builds pairs out of two lists
payments.map(_.id).zip(payments.map(_.amount))  // List[(String, BigDecimal)]

Also, notice here how tuple(s) are showing up without you even noticing it. A Map is an iterable of pairs, groupBy hands back pairs and zip builds them. The standard library leans on tuples constantly because destructuring one is more efficient (it costs a pattern rather than a class declaration), which is yet another reason why the collections API stays uniform across types.

Case classes are products with names

A tuple is an anonymous product. A case class is the same construct, but with names attached. And guess what? You have the same destructuring for free through its generated unapply.

Scala
final case class Summary(count: Int, total: BigDecimal)

def summarize(payments: List[Payment]): Summary =
  val accepted = payments.filter(_.approved)
  Summary(accepted.size, accepted.map(_.amount).sum)

val Summary(count, total) = summarize(payments) // how cool is THIS :)
val corrected = summarize(payments).copy(total = BigDecimal(99))

It a tuple and a case class provides the same functionalities, what is the point of having both of them, you may ask?

Well, first and foremost: efficiency. Tuples are more efficient than case classes. Then, there is the ergonomics: while both destructure in the same way, if you need to access a specific type within the tuple, you would need to resort to special accessors provided by the Scala compiler, named _1, _2, ... _n, which are hard to read, especially if you didn't write the code yourself, or if you come back in 6 months.

Scala
val summary: (Int, BigDecimal) = (2, BigDecimal(49.99))
summary._1  // 2,     the count
summary._2  // 49.99, the total

val named = Summary(2, BigDecimal(49.99))
named.count  // 2
named.total  // 49.99

So the general rule of thumb is that, if performance is not a concern, keep tuples confined within a function, and use case classes for domains that span across different scopes.

Arity is part of the type

Scala 3 tuples are, at the type level, an heterogeneous lists. (Int, String) is really Int *: String *: EmptyTuple, which means you can actually write code that is generic over the shape of a tuple.

Scala
val a: (Int, String)     = (1, "two")
val b: (Boolean, Double) = (true, 3.0)

val c: (Int, String, Boolean, Double) = a ++ b

def firstOf[H, T <: Tuple](t: H *: T): H = t.head

firstOf(c)  // 1, typed as Int !

Did you see what just happened over there ? a ++ b is not some runtime, lossy concatenation that returns a tuple of Any(s): the compiler computed the concatenation of all the types.

Credit where credits are due, TypeScript does this too: variadic tuple types express the same concatenation.

TypeScript
function concat<A extends unknown[], B extends unknown[]>(a: [...A], b: [...B]): [...A, ...B] {
  return [...a, ...b];
}

const c = concat(a, b);  // [number, string, boolean, number]

Java and Python, though, have no solution at all. Java generics cannot describe an arity, and Python's tuple[int, str] disappears at runtime.

Named tuples (Scala 3.7 onwards)

Named tuples, which became stable in 3.7, gives you the possibility to assign names to fields, enhancing the developer experience.

Scala
type Summary = (count: Int, total: BigDecimal)

val s: Summary = (count = 2, total = BigDecimal(49.99))
s.count  // 2

val swapped: Summary = (total = BigDecimal(49.99), count = 2)
// error: Found:    (total : BigDecimal, count : Int)
//        Required: Summary

What an agent does with two return values vs three

As you may have guessed from the various entries in our "Why Use Scala" series, AI agents produces high quality, robust code, if and only if they receive immediate feedback about what they just wrote.

In our simple example, just by adding a third type to a tuple, the Scala compiler immediately fails at every call site, so the agent knows all codepaths that require intervention. With Python you're crossing your fingers and hoping you didn't forget any, or you'll have a production incident to investigate. TypeScript compiles clean but then, every existing caller quietly ignores the value that was just added.

There is however another angle to consider: what the agent has to read. A Scala signature that returns (Int, BigDecimal) carries the arity and both types inline, and the caller that destructures it is a simple one-liner which is, once again, fully checked. If you were to write something similar in Java, you'd have to find the Summary declaration, read it, then write two or three accessor calls. It is all doable, but also, a lot of boilerplate that costs the agent both tokens and context, as we mentioned in our first post in this series about conciseness.