Desired auto-tupling leaves some things to be desired (Scala 3)

There’s been much talk about auto-tupling in general, but there’s one place where I actually feel it makes sense: When a tuple is the input.

I feel there’s some warts in how Scala handles these cases though.

def withTuple(tpl: Tuple) = ???

withTuple(1, 2) // works
withTuple(1) // Found:    (1 : Int) Required: Tuple
withTuple() // missing argument for parameter tpl of method withTuple in class App: (tpl: Tuple): Nothing

First off, I like that the first line works. It lets me keep track of the types of the arguments, which is very useful for custom string interpolation among other things.

The second line is a bit weirder. Scala refusing to auto-tuple the single argument makes the least sense to me as the type Tuple1[T] should be the single argument T. At least philosophically, right…?

No matter, it can be amended with an overload:

def actualWithTuple(tpl: Tuple) = tpl
def withTuple(tpl: Tuple) = actualWithTuple(tpl)
def withTuple[T](t: T) = actualWithTuple(Tuple1(t))

withTuple(1,2) // works
withTuple(1) // also works!
withTuple() // None of the overloaded alternatives of method withTuple in class App with types … match arguments ()

Ok, so a new error on the last one. Let’s add another overload:

// earlier definitions
def withTuple() = actualWithTuple(EmptyTuple)

withTuple(1, 2) // None of the overloaded alternatives of method withTuple in class App with types … match arguments ((1 : Int), (2 : Int))
withTuple(1)
withTuple()

Now the first one stops working…

The one way I’ve managed to work around this is with union types, like

def withTuple[T](oneOrTpl: T | Tuple = EmptyTuple) = 
  oneOrTpl match
    case tpl: Tuple => actualWithTuple(tpl)
    case one: T @unchecked => actualWithTuple(Tuple1(one))

but this feels weird (also, the match doesn’t seem able to do completion checks for the last case so I need to add an annotation to kill the runtime check warning.)

TL;DR: Could and should the auto-tuple mechanism implicitly convert T => Tuple1[T] here? And is the empty overload a bug, or am I missing something?

I would say that we need something like tuple varargs so that you could pass as many arguments to a method as you wish (including 1 or 0) and then get a tuple (preserving all the original types of its elements) to work with in the method body. However if the intention was to pass a tuple as all the arguments, the user would have to unpack it explicitly at the point of method invocation - something like

def foo(args: Any**): args.type = args
val a: EmptyTuple = foo()
val b: Tuple1[Int] = foo(1)
val c: (Int, Int) = foo(1, 2)
val pair = (1, 2)
val d: Tuple1[(Int, Int)] = foo(pair)
val e: (Int, Int) = foo(pair**)

I intentionally used ** instead of * for clarity (or we could have some different syntax) but it would probably require further research if these two could be safely unified without making things confusing for users and too difficult for the compiler to handle