Merging named tuples to create new named tuples

With the arrival of named tuples, I’m wondering if there’s any way to express more of what class hierarchies are able to express. I may be mistaken in my recollection of set theory, or am mixing nomenclature between it and type theory, but I feel that you might be able to express subtype relationships with unions? “C = A ⋃ B // merge all members of A and B”

type Person(name: String, age: Int)
type Employee = Person ⋃ (job: String, salary: Double)
val p: Person = ("norm", 40, "programmer", 100000)

First, I put as I’m not sure what the correct symbol is in Scala, if there even is one; the | union operator does not have the same meaning of taking all elements of both “sets” but instead something like disjunction where only one part is guaranteed.
Second, even if I did try | or &, that breaks the initialization for named tuples entirely. For example, creating a valid named tuple type through an intersection prevents the initialization syntax.

type Foo = (a: Int, b: String, c: Boolean)
type Bar = (a: Int, b: String, c: Float)

type T = Foo & Bar // drops c
val t: T = (a = 10, b = "hello") // error Required: T

Which probably should be raised as a bug report on it’s own.

Any ideas? I think there is potential for named tuples to offer a very elegant, powerful and unifying alternative to classes.

Named tuples are still not as powerful as proper records in languages like TS.
It is not clear to me if the idea is to eventually make them as powerful as that, or if the plan was just to add them as convenience over defining case classes for single methods and eventually improve things like Map.

This is probably a question better suited for the contributors forum, just make sure to check previous discussions first to avoid repeating the same question: https://contributors.scala-lang.org/

Named tuples already support ++ which will simply add new entries, or give you an error if there are duplicates.

The type-level equivalent is NamedTuple.Concat, which annoyingly sometimes leaks through, even though you can still access the underlying things by name:

scala> (name = "J Smith", age = 32)
val res0: (name : String, age : Int) = (J Smith,32)
                                                                                
scala> (profession = "Programmer", likesDogs = true)
val res1: (profession : String, likesDogs : Boolean) = (Programmer,true)
                                                                                
scala> res0 ++ res1
val res2:
  NamedTuple.Concat[(name : String, age : Int), (profession : String, likesDogs
     : Boolean)] = (J Smith,32,Programmer,true)
                                                                                
scala> (likesCats = true, likesDogs = true)
val res3: (likesCats : Boolean, likesDogs : Boolean) = (true,true)
                                                                                
scala> res1 ++ res3
-- [E172] Type Error: ----------------------------------------------------------
1 |res1 ++ res3
  |            ^
  |Cannot prove that Tuple.Disjoint[(("profession" : String), ("likesDogs" : String)),
  |  (("likesCats" : String), ("likesDogs" : String))] =:= (true : Boolean).
1 error found
                                                                                
scala> res2.likesDogs
val res4: Boolean = true

However, if names are not disjoint, then it’s much less clear what the default policy should be (especially but not only if types differ). So I’m a little bit wary about having a default behavior, rather than having libraries that clearly set expectations. It is absolutely something that can be done at the library level, so I think it probably should be.

I do think it would be nice to have more primitive operations one could use to build other things. Like addMissing or copyFrom, where you append any missing fields, or create a new copy where the new values are obtained from the argument (but, as usual with copy, you can switch the types).

However, at the type level these things are going to end up being an awkward mishmash of CopyFrom[MissingAdded[Original, Extra], Update] so I think the degree to which they can be utilized without a fundamentally different set of language features is somewhat limited.

4 Likes

I also wish there was a standardized way to “simplify” NamedTuple types down to a simple names only.

Welcome to Scala 3.7.0 (21.0.2, Java OpenJDK 64-Bit Server VM).
Type in expressions for evaluation. Or try :help.
                                                                                
scala> import NamedTuple.NamedTuple
                                                                                
scala> val t1 = (name = "J Smith", age = 32)
val t1: (name : String, age : Int) = (J Smith,32)
                                                                                
scala> val t2 = (profession = "Programmer", likesDogs = true)
val t2: (profession : String, likesDogs : Boolean) = (Programmer,true)
                                                                                
scala> val complicatedType = (t1 ++ t2)
val complicatedType:
  NamedTuple.Concat[(name : String, age : Int), (profession : String, likesDogs
     : Boolean)] = (J Smith,32,Programmer,true)
                                                                                
scala> extension [N <: Tuple, V <: Tuple](t: NamedTuple[N, V])
     |   inline transparent def simplifyType: NamedTuple[N, V] = t.asInstanceOf[NamedTuple[N, V]]
                                                                                
scala> val simpleType = complicatedType.simplifyType
val simpleType: (name : String, age : Int, profession : String, likesDogs :
  Boolean) = (J Smith,32,Programmer,true)

Does something like this already exist in the standard library? In most cases and for most people, I think it would be better if the types returned by operations such as ++ were simplified by default.

I think that’s just the case of improving the printer. We did the same for the presentation compiler in improvement: Don't dealias named tuples for type hints by tgodzik · Pull Request #23013 · scala/scala3 · GitHub

actually it is because of the use of Names and DropNames types. If they were replaced with something that actually decomposes the types it infers better in the REPL, which i have in this PR Add experimental NamedTuple copyFrom method by bishabosha · Pull Request #23135 · scala/scala3 · GitHub

e.g.

// OLD
/** Type of the concatenation of two tuples `X` and `Y` */
type Concat[X <: AnyNamedTuple, Y <: AnyNamedTuple] =
  NamedTuple[Tuple.Concat[Names[X], Names[Y]], Tuple.Concat[DropNames[X], DropNames[Y]]]

// NEW
/** Type of the concatenation of two tuples `X` and `Y` */
type Concat[X <: AnyNamedTuple, Y <: AnyNamedTuple] =
  (Decompose[X], Decompose[Y]) match
    case ((nx, vx), (ny, vy)) =>
      NamedTuple[Tuple.Concat[nx, ny], Tuple.Concat[vx, vy]]
2 Likes

Yeah, in my code I never use X <: AnyNamedTuple only Ns <: Tuple, Ts <: Tuple with NamedTuple[Ns, Ts], precisely so I get the properly-decomposed types.

Just tested this

type Person = (name: String, age: Int)
type Employee = (job: String, salary: Double)

def fn(x: Person & Employee) = ???


val e: Person = ("Norm", 20)
val u: Employee = ("programmer", 100000)

fn(e ++ u)
// Found:    NamedTuple.Concat[(name : String, age : Int), (job : String, salary : Double)]
// Required: Playground.Person & Playground.Employee

++ results in a combination of all fields, which is what & describes, yet is not considered a & by the type system. I think this shows that this NamedTuple.Concat wrapper approach Scala does in the absence of serious tuple/record type semantics like TS offers is inadequate.

a ++ b should simply result in the type A & B, no wrappers.

Yes, Scala is very lacking in this regard. & is the greatest lower bound of two types, but there is no way to instantiate a member of it (except subtypes of the &). Named tuples are a fairly new feature, so it’s still being developed and having new features added to it. Scala also does not have true record / structural types, it has a Selectable trait but that’s limited in comparison.

Scala primarily depends on path dependent types but many people either don’t know how to use it or find it unintuitive / limiting. Scala’s type system is a unique beast, very good for some things, not for others.

You’ll keep running into more and more limitations like this for your project. Some users come here trying to do compiler-y, language-y type things and run into these issues you are running into. Maybe consider a different approach, or even a different language? At the very least it might be a good idea to study the type system a little bit beforehand, to understand the limitations. (See links to the spec in blog post linked above) After some study and learning it might turn out that Scala is adequate for your needs.

1 Like

I would lose far more switching to another language, many aspects of my design are only possible with Scala’s combination of features. Hopefully Scala’s support for tuples improves in the future.

I thought I might be able to just correctly implement ++ to result in A & B myself using an extension and forcing the compiler using asInstanceOf

type X = (a: Int, y: String)
type Y = (b: Int, u: Boolean)

extension [A <: Tuple](a: A)
  infix def combine[B <: Tuple](b: B) = (a ++ b).asInstanceOf[A & B]


val x: X = (3, "k")
val y: Y = (5, true)

val c: X & Y = x combine y
// java.lang.ClassCastException:
// class scala.Tuple4 cannot be cast to class scala.Tuple2
// (scala.Tuple4 and scala.Tuple2 are in unnamed module of loader sbt.internal.ScalaLibraryClassLoader @25e662fd)

Extensions for tuples look harder to do than extensions for sequences.

No it shouldn’t, because named tuples are not const-keyed maps. For maps, that’s true. But tuples are ordered, and Person has the property that p(1) is an Int while Employee has the property that e(1) is a Double. and p ++ e has the property that (p ++ e)(2) is a String, while neither Person nor Employee have .apply(2) defined at all.

So it’s not just & not working the way you want. It’s also named tuples not working the way you want. They are just tuples with an extra way to access the fields (i.e. by name).

2 Likes

You are right. I don’t care about order of fields or numerical indexing. I really want a struct or “record” type that I can concatenate with other structures to produce intersection types. Or a way to that with case classes ideally. (I’m not going to be satisfied with string literals for the field names)

You can avoid multiple-inheritance, and avoid composition, if you have a way to say obj: A & B = A(...) ++ B(...), as a very nice way to model certain problems.

In theory, you can leverage named tuple syntax and compile-time operations to create a map that has typed field names (as a tuple of string constants) and which you access using the same kind of inlined selectDynamic that allows you to index named tuples. Or it could literally be a type of named tuple (e.g. one with sorted argument names). Part of the trick is to do an insertion sort based in types. Here’s the index calculation, for instance:

import compiletime.ops.str.*
import compiletime.ops.int.*
type InsertionPoint[X, Ts, N <: Int] <: Int = Ts match
  case EmptyTuple => N
  case t *: rest => X < t match
    case true => N
    case _ => InsertionPoint[X, rest, N+1]

However, it’s a lot of work to get the whole thing assembled. I wouldn’t want to take that on.