Expanding/changing `Selectable` based on upcoming Named Tuples feature

Now that Named Tuples may become a reality, I want to bring up a pain point that this feature can help solve.

The problem with structural types

Currently structural types are very limiting. The only way to get a refinement is through casting and requires every function that transforms the refined object to keep the refinement along the way.

For example, say I have a class Foo[T]. I need to have specific fields available (at compile-time) for instances of Foo according to the type T. Structural types provide no such functionality.

Solving the problem via Selectable and Implicit Conversions

The only way to achieve this is by extending Selectable and creating an ugly implicit conversion and a helper macro like so:

final class Foo[T] extends Selectable:
  def selectDynamic(name: String): Any = /* runtime field selection */
given [T](using r: Refiner[T]): Conversion[Foo[T], r.Out] =  _.asInstanceOf[r.Out] //just forcing the compiler to cooperate 
trait Refiner[T]{ type Out <: Foo[T] }
object Refiner:
  transparent inline given [T]: Refiner[T] = ${ refineMacro[T] }
  def refineMacro[T](using Quotes, Type[T]): Expr[Refiner[T]] =
    import quotes.reflect.*
    val tTpe = TypeRepr.of[T]
    val fooTpe = TypeRepr.of[Foo[T]]
    val fields: List[(String, TypeRepr)] = /*your field logic here*/
    val refined = fields.foldLeft(fooTpe) { case (r, (n, t)) => Refinement(r, n, t) }
    val refinedType = refined.asTypeOf[Foo[T]]
    '{ new Refiner[T]{ type Out = refinedType.Underlying }}
  end refineMacro
end Refiner

This is clearly ugly, relies on a clunky implicit conversion mechanism (like implicit classes did) and also fails to support field auto-completion by the IDE because the IDE cannot know what fields are actually available.

Proposal: Expanding Selectable with a Named Tuple Fields type member

We add to Selectable a Tuple type member Fields.

trait Selectable:
  type Fields <: Tuple

Rules:

  • If Fields is unbounded further by the user (the upper bound remains Tuple), then the current behavior of Selectable extended objects remains the same. (If the user just bounds with NonEmptyTuple we treat it the same also).
  • If Fields is an EmptyTuple, there are no selectable fields available and we should get a compiler error when trying to select a field that does not already exist.
  • If Fields is some kind of non-zero Tuple arity, then the available fields and their types will be defined by the tuple type. This should work with both named and unnamed tuples just the same, but of course generally more useful with named tuples.
7 Likes

The proposed feature reminds me of the way we can “forward” selection in Swift so I thought it might be useful to quickly present the way things work over there, in case there are some parts of the design we can copy. Otherwise, my apologies for the noise.

In Swift, we can customize the runtime selection of a type’s member using a feature called “dynamic member lookup”. Here’s a simple example:

typealias Version = (major: Int, minor: Int)

@dynamicMemberLookup
struct Configuration {
  var version: Version
  var user: [String: Int]
  subscript(dynamicMember k: String) -> Int? { user[k] }
}

var s = Configuration(version: (major: 2, minor: 1), user: ["revision": 2])
print(s.version.minor) // compile-time selection
print(s.revision)      // run-time selection

I believe annotating a Swift type with @dynamicMemberLookup is similar to extending Selectable in Scala. Please correct me if I’m wrong.

Swift has a notion of “key paths”, which are values denoting a how to access a specific field from a root type. They can be seen as lenses whose semantics is understood by the compiler. Here’s an example:

var s = Configuration(version: (major: 2, minor: 1), user: ["revision": 2])
var k = \Configuration.version.minor
print(s[keyPath: k])   // compile-time selection

We can combine the two features to “forward” a member lookup at compile-time. For example:

@dynamicMemberLookup
struct Configuration {
  /* same as before */
  subscript<U>(dynamicMember k: KeyPath<Version, U>) -> U { version[keyPath: k] }
}

var s = Configuration(version: (major: 2, minor: 1), user: ["revision": 2])
print(s.major)         // compile-time selection
print(s.version.minor) // compile-time selection
print(s.revision)      // run-time selection

Of course the root of a key path may be generic as well. So IIUC, here’s how to implement OP’s example in Swift:

@dynamicMemberLookup
struct Foo<T> {
  var m: T
  subscript<U>(dynamicMember k: KeyPath<T, U>) -> U { m[keyPath: k] }
}

var s = Configuration(version: (major: 2, minor: 1), user: ["revision": 2])
var t = Foo(m: s)
print(t.major)         // compile-time selection

What I like about this feature is that it uniformly applies to every compile-time “selectable” thing, or in Swift terms any kind of compile-time member lookup. That’s because we can create key paths for every property that appears in the API of a type, no matter whether the type is structural or not, no matter whether the property is computed or not. Indeed, notice that t.major requires two key path applications, but that is transparent to the author of Foo.

1 Like

I am not yet convinced that structural types would not be enough to do this.

What would be the difference between

Selectable { type Fields = (name: String, age: Int) }

and

Selectable { val name: String, age: Int }

? For me, the point of structural types is that they are facades for (subsets of) the APIs of class types, whereas the point of named tuples is that they are stand-alone value types: tuples with a more readable access mechanism.

It would be good to see concrete usage examples, expressed with the best possible idiom for structural types and then expressed using the proposed feature.

The difference as I see it between them is that the first one a match type can generate on its own given sufficient input, while you need a macro to generate a dynamic version of the second one.

2 Likes

Yes, I have come to appreciate that now as well. It gives us a way to compute selectable fields, which is powerful, but also looks well contained, compared to (say) implicit conversions. I am warming up to this!

2 Likes

This is now implemented and documented as part of the named tuples implementation #19174.

3 Likes

That’s awesome!
I see that the change is only in the typer. What is required to change so the field list will be available in completions for editors like Metals?

I believe one would need to work on the presentation compiler plugin in dotc. There are methods that provide the possible completions. Since the changes in the Typer are quite small, I don’t think this would be a big problem either.