Upgrade to Pro — share decks privately, control downloads, hide ads and more …

Going Structural with Named Tuples

Going Structural with Named Tuples

Exploring the benefits of Structural Typing in the Scala Programming language, and how it is made more convenient with the introduction of the Named Tuples language feature. Showing demos of ORM, data frame programming, HTTP API with server and client.

Background info - Named Tuples add labels to generic tuples, enhancing Scala's already existing structural types by trading the convenience of width sub-typing (but arbitrary structure) for deterministic size and order.

Jamie Thompson

March 27, 2025
Tweet

More Decks by Jamie Thompson

Other Decks in Programming

Transcript

  1. • Engineer @ Mibex Software • ex. Scala Center 2019-2024

    Recent Scala OSS Contributions • Mill Scala 3 upgrade • Scala 3 build pipelining • -Ytasty-reader Scala 2 flag • Mirror Framework bishabosha.github.io About Me 📖 2
  2. Learning Goals 🎯 • Why are Named Tuples being introduced?

    • What is structural typing, and why is it useful? • How to use Named Tuples? • What are their limitations? (🚨 Nerd Snipe alert 🚨) 3
  3. Pattern Matching woes 5 def foo(m: MyUglyCaseClass) = m match

    case MyUglyCaseClass( _, _, _, _, ..., bar, _, _ ) => bar pattern_match.scala 😭
  4. Pattern Matching woes 6 def foo(m: MyUglyCaseClass) = m match

    case MyUglyCaseClass( bar = bar, ) => bar pattern_match.scala There is a scala feature request for this since 2012! 🤔
  5. class Seq[T]: def partition(f: T => Boolean): (Seq[T], Seq[T]) =

    ??? Multiple return values multiple_returns.scala 7 which side is which? 😭
  6. class Seq[T]: def partition(f: T => Boolean): (matches: Seq[T], rest:

    Seq[T]) = ??? Multiple return values multiple_returns.scala 8 🤔
  7. Address shortcomings of structural typing 9 type Person = Record

    { val name: String; val age: Int } def test(person: Person) = assert(person.name == "Jamie") structural.scala 🤔 “width” subtyping Unordered field access Since Scala 2.6.0
  8. Address shortcomings of structural typing 10 class Record(data: Map[String, Any])

    extends Selectable: def selectDynamic(name: String): Any = data(name) Record.scala val jamie = Record(Map( "name" -> "Jamie", "age" -> 28 )).asInstanceOf[Person] // casting!!! jamie.name // jamie.selectDynamic(“name”).asInstanceOf[...] 😱 example.scala Hard to “safely” inspect/construct without macros
  9. Address shortcomings of structural typing 11 type FrontMatter = model.FrontMatter

    { val title: String val description: String val isIndex: Boolean val redirectFrom: List[String] val publishDate: String } Blog.scala Schema for YAML in markdown
  10. Address shortcomings of structural typing 12 class FrontMatter(data: Map[String, List[String]])

    extends Selectable: def selectDynamic(name: String): Any = name match case s"is$_" => data .get(name) .flatMap(ls => if ls.isEmpty then Some(true) else ls.head.toBooleanOption ) .getOrElse(false) case s"${_}s" => data.get(name).getOrElse(Nil) case _ => data.get(name).flatMap(_.headOption).getOrElse("") Blog.scala Either stick to convention, or pass type-derived schema in constructor. Again - you can’t inspect structural refinement types without macros 🫣
  11. Why Structural Types? 14 • Avoid naming fatigue, if only

    the shape matters • Temporary, ad-hoc types • narrow “view” over data (e.g. forget unused fields) • compose arbitrary values while preserving types • Derive new types from values
  12. val people = spark.read.parquet("...") val department = spark.read.parquet("...") people.filter("age >

    30") .join(department, people("deptId") === department("id")) .groupBy(department("name"), people("gender")) .agg(avg(people("salary")), max(people("age"))) DSLs spark.scala 15 Named Tuples could give better type safety without macros Example from https://spark.apache.org/docs
  13. type Id[T] = T // City[Id] ==> return rows from

    DB class Expr[T] // City[Expr] ==> select columns in a query case class City[T[_]]( id: T[Int], name: T[String], countryCode: T[String], district: T[String], population: T[Long] ) ORM / SQL Query wrapper scalasql.scala 16 Can named tuples provide a different way? val fewLargestCities = db.run( City.select .sortBy(_.population).desc .drop(5).take(3) .map(c => (c.name, c.population)) ) query.scala Example from github.com/com-lihaoyi/scalasql “Quoted” DSL Diff backends
  14. What are Named Tuples? type Person = (name: String, age:

    Int) val Jamie = (name = "Jamie", age = 28) assert(Jamie.name == "Jamie") assert(Jamie.age == 28) Person.scala 18
  15. What are Named Tuples? type Person = (name: String, age:

    Int) val Jamie = (name = "Jamie", age = 28) assert(Jamie.name == "Jamie") assert(Jamie.age == 28) Person.scala Type syntax 19
  16. What are Named Tuples? type Person = (name: String, age:

    Int) val Jamie = (name = "Jamie", age = 28) assert(Jamie.name == "Jamie") assert(Jamie.age == 28) Person.scala Value syntax 20
  17. What are Named Tuples? type Person = (name: String, age:

    Int) val Jamie = (name = "Jamie", age = 28) assert(Jamie.name == "Jamie") assert(Jamie.age == 28) Person.scala field selection 21
  18. Type Inference 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 // ) accumulator.scala 22
  19. def printPersonA(p: Person) = p match case (age = a,

    name = n) => println(s"$n is $a years old") def printPersonB(p: Person) = p match case (name = n) => println(s"$n is ${p.age} years old") Named Tuple patterns 🧩 patterns.scala 23 swap order forget fields
  20. case class Point(x: Int, y: Int) def printX(p: Point) =

    p match case Point(x = x) => println(x) Named Tuple patterns 🧩 patterns.scala (case class) Coming to case classes too! 24
  21. Comparisons To Product/Structural type Person = (name: String, age: Int)

    Person.scala case class Person(name: String, age: Int) type Person = Record {val name: String; val age: Int} 🤔 Q: What am I ? Product type? Structural type? 26
  22. ✅ type Person = Record {val name: String; val age:

    Int} ❌ case class Person(name: String, age: Int) A: Product type Comparisons To Product/Structural type Person = (name: String, age: Int) Person.scala 27 But not “scala.Product”
  23. type HasName = (name: String) type HasAge = (age: Int)

    def person: HasName & HasAge = ??? person.name person.age Not A Structural type Person.scala ❌ Can’t create intersection of fields Same underlying field Overlapping field 28 uninhabited
  24. Type Syntax Sugar type Person = (name: String, age: Int)

    type Person = NamedTuple[("name", "age"), (String, Int)] ⬇ 29
  25. type Person = NamedTuple[("name", "age"), (String, Int)] type Person =

    (name: String, age: Int) ⬇ 30 Only at compile-time Type Syntax Sugar
  26. type Person = NamedTuple[("name", "age"), (String, Int)] ⬇ 31 Underlying

    type type Person = (name: String, age: Int) Type Syntax Sugar
  27. NamedTuple[("name" *: "age" *: EmptyTuple), (String *: Int *: EmptyTuple)]

    ⬇ 32 Field labels are first class types No macro required! type Person = (name: String, age: Int) type Person = NamedTuple[("name", "age"), (String, Int)] ⬇ Type Syntax Sugar
  28. opaque type AnyNamedTuple opaque type NamedTuple[N <: Tuple, +T <:

    Tuple] <: AnyNamedTuple = T NamedTuple.scala 33 Type Syntax Sugar
  29. Named Tuples are Product types type Person = (name: String,

    age: Int) val p: Person = ("Alice", 42).withNames[("name", "age")] assert(p(1) == p.age) summon[Mirror.Of[Person]].fromProduct(p.toTuple) Person.scala ✚ O(1) Random-access fields ✚ Type class derivation support ✚ Zero-cost conversion to/from tuple 35
  30. val nameT = (name = "Alice") val ageT = (age

    = 42) val person: Person = nameT ++ ageT person(0) == person.name person(1) == person.age Like a Generic Tuple Person.scala Generic Operations! Tuple concatenation 36
  31. val nameT = (name = "Alice") val ageT = (age

    = 42) val person: Person = nameT ++ ageT val err = ageT ++ ageT Like a Generic Tuple Person.scala Generic Operations! Error: can’t prove disjoint ❌ 37
  32. Like a Generic Tuple type Person = (name: String, age:

    Int) val optPerson: NamedTuple.Map[Person, Option] = (name = Some("Alice"), age = None) PersonMapped.scala 38 Generic Operations! Each field is wrapped with Option[T]
  33. case class City(name: String, population: Int) val Warsaw: NamedTuple.From[City] =

    (name = "Warsaw", population = 1_800_000) Like a Generic Tuple City.scala Generic Operations! Provide schema from a case class 39
  34. class Seq[T]: def partition(f: T => Boolean): (matches: Seq[T], rest:

    Seq[T]) = ??? Multiple return values multiple_returns.scala 41 self-documenting code
  35. val directions = List( (dx = 1, dy = 0),

    (dx = 0, dy = 1), (dx = -1, dy = 0), (dx = 0, dy = -1) ) Test input data Data 42 Ad-hoc data
  36. Batch jobs val config = ( currency = (code =

    "USD", symbol = "$"), invoice = ( id = 5, period = (start = (2025, 1, 27), days = 30) ), listings = ( items = List( (desc = "Premium Insurance (1 year)", qty = 2, price = 250_00), (desc = "Samsung Galaxy S13", qty = 1, price = 999_00) ), taxRate = 12 ), business = ( name = "Acme Corp.", ... make_invoice.sc 43 Ad-hoc data
  37. Custom types with Structural selection class Foo extends scala.Selectable: type

    Fields <: AnyNamedTuple // concrete named tuple type def selectDynamic(name: String): Any = ??? QueryDSL.scala 44 val f: Foo { type Fields = (x: Int, y: Int) } = ??? f.x // f.selectDynamic("x").asInstanceOf[Int] Example.scala Structural field
  38. 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) Read/Write JSON Data Generate ad-hoc JSON codecs 46 Using softwaremill/sttp and com-lihaoyi/upickle github.com/bishabosha/scalar-2025 Ollama chat api
  39. 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] fieldMapper.scala 47 github.com/bishabosha/scalar-2025 Convert via scala.deriving.Mirror Concat with ++ Could be made lazy?
  40. DataFrame 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)) dataframe.scala shape: (8, 2) ┌───────────┬──────┐ │ lowerCase ┆ freq │ ╞═══════════╪══════╡ │ the ┆ 2 │ │ over ┆ 1 │ │ quick ┆ 1 │ │ lazy ┆ 1 │ │ jumps ┆ 1 │ │ brown ┆ 1 │ │ dog ┆ 1 │ │ fox ┆ 1 │ └───────────┴──────┘ 48 github.com/bishabosha/scalar-2025 Structural field
  41. case class City( id: Int, name: String, countryCode: String, district:

    String, population: Long ) object City extends Table[City] Data query Domain.scala 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 Query.scala Example adapted for Named Tuples from github.com/com-lihaoyi/scalasql 49 dropped ScalaSql’s required type param github.com/bishabosha/scalar-2025 Structural field
  42. Custom types with Structural selection QueryDSL.scala case class City(name: String,

    pop: Int) class Expr[Schema] extends scala.Selectable { type Fields = ... } val c: Expr[City] = ??? // City.select.filter(c => c.name === ...) // ^ c.Fields =:= (name: Expr[String], pop: Expr[Int]) val name: Expr[String] = c.name // c.selectDynamic(“name”) val pop: Expr[Int] = c.pop // c.selectDynamic(“pop”) 50
  43. Endpoint Derivation val schema = HttpService.endpoints[NoteService] // schema.Fields =:= (

    // createNote: Endpoint[...], // getAllNotes: Endpoint[...] // deleteNote: Endpoint[...], // ) client.scala object model: 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 NoteService.scala 52 github.com/bishabosha/scalar-2025 Using bishabosha/ops-mirror
  44. HTTP Client: Frontend val schema = HttpService.endpoints[NoteService] val client =

    Client.ofEndpoints( schema, baseURI = "http://localhost:8080/" ) // client.Fields =:= ( // createNote: PartialRequest[...], // getAllNotes: PartialRequest[...], // deleteNote: PartialRequest[...], // ) client.scala object model: 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 NoteService.scala 53 github.com/bishabosha/scalar-2025
  45. HTTP Client: Frontend val schema = HttpService.endpoints[NoteService] val client =

    Client.ofEndpoints( schema, baseURI = "http://localhost:8080/" ) client.createNote.send( (body = (title = ..., content = ...)) ) // : Future[Note] client.getAllNotes.send(Empty) // ... client.deleteNote.send((id = ...)) // client.scala object model: 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 NoteService.scala 54 github.com/bishabosha/scalar-2025
  46. HTTP Server object model: 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 NoteService.scala val schema = HttpService.endpoints[NoteService] val app = router(schema) def routes(db: DB): app.Routes = ( createNote = p => db.run(...), getAllNotes = _ => db.run(...), deleteNote = p => db.run(...) ) val server = app .handle(routes(LogBasedStore())) .listen(port = 8080) server.scala 55 github.com/bishabosha/scalar-2025
  47. HTTP Server object model: 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 NoteService.scala def routes(db: DB): app.Routes = ( createNote = p => db.run( // p: (body: CreateNote) Note.insert.values(p.body) ), getAllNotes = _ => db.run( // _: NamedTuple.Empty Note.select ), deleteNote = p => db.run( // p: (id: String) Note.delete.filter(_.id == p.id) ) ) server.scala 56 github.com/bishabosha/scalar-2025 Backend agnostic
  48. Type computation case class City(name: String, pop: Int) val c:

    Expr[City] = ??? // ^ c.Fields =:= (name: Expr[String], pop: Expr[Int]) • Match types • Mirror Framework • scala.compiletime.ops package • Inline match • Macros 58 github.com/bishabosha/scalar-2025 Inspect the demos
  49. No Recursion recursion.scala val rec = ( x = 23

    y = x ) 60 Outer scope recursion.scala val rec = new Selectable { val x = 23 val y = x } Inferred structural type ✅ ❌
  50. Match Types builtins.scala Pattern match on types! NamedTuple.Map[T, [A] =>>

    Option[A]] // apply type lambda to each elem NamedTuple.Concat[T, U] // join two Named Tuple types NamedTuple.From[C] // convert case class to Named Tuple NamedTuple.Names[T] // extract the names tuple NamedTuple.DropNames[T] // extract the values tuple // ...more in NamedTuple object 65
  51. Match Types custom.scala Pattern match on types! type Select[N <:

    String, T <: AnyNamedTuple] <: Option[Any] = T match case NamedTuple[ns, vs] => (ns, vs) match case (N *: _, v *: _) => Some[v] case (_ *: ns, _ *: vs) => Select[N, NamedTuple[ns, vs]] case (EmptyTuple, EmptyTuple) => None.type summon[Select["name", (name: String, age: Int)] =:= Some[String]] summon[Select["age", (name: String, age: Int)] =:= Some[Int]] summon[Select["???", (name: String, age: Int)] =:= None.type] 66