Scala's New Named Tuples: why you should embrace structural types
- Monday, 14 April 2025
- 8 minute read
- all articles
Scala 3.7 stabilises the Named Tuples proposal, giving users new syntax for structural types and values, and makes it simpler to do typelevel programming without macros. Read on for a summary of the key features and benefits of named tuples, and practical examples.
This article is an abridged version of my talk "Going Structural with Named Tuples" from Scalar 2025. Please watch for more entertainment value (and even more information!) You can also view more of the examples in the GitHub repo bishabosha/scalar-2025.
Motivation
So why is the feature being introduced?
Named arguments in pattern matching
If you are tired of long lists of wildcards when pattern matching on case classes, now you can choose exactly which fields to match on, and ignore the rest:
// Instead of: case Person(_, age, _, _) => ..., You can:
person match { case Person(age = a) => ... }
Label multiple return values
With named tuples, you can provide names to each value, where previously a tuple (with unclear meaning) would be used:
// Instead of: def partition(seq: Seq[T]): (Seq[T], Seq[T]), You can:
def partition(seq: Seq[T]): (matches: Seq[T], others: Seq[T])
Address shortcoming of current structural typing
Scala has had structural types since 2.6.0 (released in 2007), but named tuples make them easier to use without macros or casting.
Why use structural types?
- Avoid rigidity: Only the fields matter, simplifying the usage of data, rather than planning a complex class hierarchy.
- Ad-hoc Types: temporary values in a complex expression (such as method chaining) are well suited for structural types, avoiding the need for boilerplate code.
- Narrow Views: Only track the fields you care about from larger data structures (e.g., JSON APIs) to reduce coupling.
- Composition: Structural values can be combined, preserving the fields of both while preserving type safety.
- Schema based derivation: Derive types from data to validate expressions, or make more flexible programs that compose easily.
An overview of the status-quo for structural typing in Scala
To summarise, structural types (opposed to nominal types) let you define types whose type equality is defined by their members (fields and methods) rather than the name of the type.
// Example: Structural Type
type Person = Record {
val name: String
val age: Int
}
def greet(p: Person): Unit = println(s"Hello, ${p.name}!")
Typically, you create a structural type by "refining" an arbitrary class like Record
with some structural members, which don't necessarily correspond to a real existing field.
The idea is that a type could refine Record
with as many structural members as you want, and as long as it has the name
and age
fields, it is a subtype of Person
, which also known as "width subtyping".
Non-determinism
The main issue is that with this representation of structural types in Scala, there is no deterministic mapping between fields and their underlying representation in memory, meaning that names must be resolved dynamically at runtime.
To illustrate, here is an example of how to define a class capable of structural typing in scala:
class Record extends scala.Selectable:
def selectDynamic(name: String): Any = ???
So for the Person
type, accessing the name
and age
fields will delegate to the selectDynamic
method of the Record
class. The concrete structural refinements that could exist are completely opaque to the method, so there is not a way to optimise the representation, therefore often you must resort to a hashmap for storage, or use reflection for access if there are real fields.
Named Tuples will address some of these shortcomings, without the need for macros.
Named Tuples Overview
As mentioned above, Named Tuples are coming in Scala 3.7.0 (after being proposed and refined in SIP-58).
Syntax
In definition and use site, they intentionally look like a case class constructor without the leading class name.
// Named Tuple Type Definition
type Person = (name: String, age: Int)
// Value Syntax
val person: Person = (name = "Alice", age = 30) // Or ("Alice", 30)
// Selecting Fields
assert(person.name == "Alice")
assert(person.age == 30)
Type Inference
Types for Named Tuple literals are inferred, meaning that the fields only need to be named once. This is useful for creating "on the fly" object values, such as local variables that aggregate multiple values.
def makeAccumulator() =
var acc = 0
(
add = (x: Int) => acc += x,
reset = () => acc = 0,
get = () => acc
)
val acc = makeAccumulator()
// acc: (
// add : Int => Unit,
// reset : () => Unit,
// get : () => Int
// )
Pattern Matching
Deconstruct named tuples by field names in any order, or even ignore fields.
person match
case (name = n, age = a) => println(s"Name: $n, Age: $a")
case (age = a, name = n) => println(s"Name: $n, Age: $a")
case (name = n) => println(s"Name: $n")
Implementation
Under the hood, named tuples are zero-cost wrappers around standard tuples. Their labels only exist at compile time, as illustrated by the following desugaring of the types:
// three equivalent types
type Person = (name: String, age: Int)
type Person = NamedTuple[("name", "age"), (String, Int)]
type Person = NamedTuple[("name" *: "age" *: EmptyTuple), (String *: Int *: EmptyTuple)]
The NamedTuple
type is a pair of two tuple types: a tuple of labels, and a tuple representing the underlying type of the fields. NamedTuple
itself is a zero-cost wrapper over its second argument.
With this formulation, named tuple types can be constructed programatically from first class types, without macros. (Conversely to construct a type refinement programatically you must use the Quotes reflection API.) This leads to a simpler design for API's that work with named tuples.
Generic Operations
Named tuples support zero-cost conversions to and from tuples, type class derivation (using Mirror
), and structural operations such as concatenation (++
operator) with compile-time checks for disjoint names. Finally you can convert a case class type to a named tuple type with equivalent fields:
// conversions to/from tuple
val alice: Person = ("Alice", 42).withNames[("name", "age")]
assert(alice(1) == alice.age)
summon[Mirror.Of[Person]].fromProduct(alice.toTuple)
// concatenation
val nameT = (name = "Alice")
val ageT = (age = 42)
val person: Person = nameT ++ ageT
person(0) == person.name
person(1) == person.age
// type operations: wrap each field
val optPerson: NamedTuple.Map[Person, Option] =
(name = Some("Alice"), age = None)
// type operations: use case class as a schema
case class City(name: String, population: Int)
val Warsaw: NamedTuple.From[City] =
(name = "Warsaw", population = 1_800_000)
New and improved structural types
Now programatic structural types can declare a Fields
member type, which should be some concrete named tuple type:
class Expr[Schema] extends Selectable:
type Fields = NamedTuple.Map[NamedTuple.From[Schema], Expr]
def selectDynamic(name: String): Any = ???
The key improvement is that the definition of the selectDynamic
method can inspect the Fields
type, which means you can adjust the implementation based on the expected type, or enhance safety through further validation.
Named Tuples also improve the ergonomics of defining such a type, because the NamedTuple.From
type (convert case class types to named tuples), and type-level operations such as Map
make it easy to derive new types from an existing schema.
Demos & Examples
Check out the GitHub repository bishabosha/scalar-2025 to see and run the following demos.
In preparation for the talk, I wanted to push the boundaries of what's possible with named tuples, here is a short list of what I managed to achieve:
- JSON Conversion: Dynamically generating JSON serializers and deserializers.
// chatting with Ollama val r = sttp.client4.quick.quickRequest .post(uri"http://localhost:11434/api/chat") .body( upickle.default.write( ( model = "gemma3:4b", messages = Seq( ( role = "user", content = "write me a haiku about Scala" ) ), stream = false, ) ) ) .send() val msg = upickle.default.read[(message: (content: String))](r.body) println(msg.message.content)
- Chimney-like Transformations: Converting between different versions of data structures (e.g., to add missing fields).
// type conversions case class UserV1(name: String) case class UserV2(name: String, age: Option[Int]) def convert(u1: UserV1): UserV2 = u1.asNamedTuple .withField((age = None)) .as[UserV2]
- Data Frame Operations: Performing type-safe data analysis operations (similar to Spark).
val text = "The quick brown fox jumps over the lazy dog" val toLower = (_: String).toLowerCase val stats = DataFrame .column((words = text.split("\\s+"))) .withComputed((lowerCase = fun(toLower)(col.words))) .groupBy(col.lowerCase) .agg(group.key ++ (freq = group.size)) .sort(col.freq, descending = true) println(stats.show(Int.MaxValue))
- SQL Queries: use a case class as a schema for structural selection of columns
case class City( id: Int, name: String, countryCode: String, district: String, population: Long ) object City extends Table[City] val allCities: Seq[City] = db.run(City.select) // Adding up population of all cities in Poland val citiesPop: Long = db.run: City.select .filter(c => c.countryCode === "POL") .map(c => c.population) .sum
- Full-Stack Web Application: Building a CRUD application with type-safe endpoints and database interactions.
type Note = (id: String, title: String, content: String) type CreateNote = (title: String, content: String) trait NoteService derives HttpService: @post("/api/notes") def createNote(@body body: CreateNote): Note @get("/api/notes") def getAllNotes(): Seq[Note] @delete("/api/notes/{id}") def deleteNote(@path id: String): Unit val schema = HttpService.endpoints[NoteService] val app = router(schema) def routes(db: DB): app.Routes = ( createNote = p => db.run( Note.insert.values(p.body) ), getAllNotes = _ => db.run( Note.select ), deleteNote = p => db.run( Note.delete.filter(_.id == p.id) ) ) val server = app .handle(routes(LogBasedStore())) .listen(port = 8080)
To get a broader overview and understanding, watch my talk "Going Structural with Named Tuples" from Scalar 2025. Again inspect and learn from the examples in the GitHub repo bishabosha/scalar-2025.