Allow specification of a subset of type parameters

Currently, when invoking a function, one must specify all of the type parameters explicitly or leave all of them to be inferred by the compiler. Sometimes, one or more type parameters are inferrable while others are not and it is unergonomic specify the inferrable parameter. For example, consider a function that declares that it’s parameters can be converted given some typeclass instance:

trait Into[I, O] {
  def into(i: I): O
}
case class MyInt(i: Int)
case class MyLong(l: Long)
given Into[MyInt, MyLong] with {
  def into(i: MyInt): MyLong = MyLong(i.i)
}

case class MyFloat(f: Float)
case class MyDouble(d: Double)
given Into[MyFloat, MyDouble] with {
  def into(i: MyFloat): MyDouble = MyDouble(i.f)
}

given Into[MyInt, MyDouble] with {
  def into(i: MyInt): MyDouble = MyDouble(i.i)
}


def myPlus[I, O](a: I, b: I)(using Into[I, O]): O = ???

myPlus(MyInt(1), MyInt(2)) will not compile because O is unknown, but val o: MyLong = myPlus(MyInt(1), MyInt(2)) will. It is not always the case that using a type ascription can solve the problem. It would be nice if it were possible to do something like myPlus[?, MyLong](MyInt(1), MyInt(2)) and let the compiler infer what is already known, though there are other possible approaches and this post is not meant to champion one specific proposal.

There are several ways to address this situation:

  1. Make use of curried type parameters. This is possible, but very unergonomic. I think the best you can do is
def myPlusCurried[O]: [I] => (I, I) => (Into[I, O]) ?=> O = [I] => (a: I, b: I) => {
  (x: Into[I, O]) ?=> ???
}
  1. Add syntax for “infer me” type parameters. For implicit/given parameters, we can already accomplish the analog with implicitly/summon. As suggested above, something like myPlus[?, MyLong](MyInt(1), MyInt(2)) or myPlus[_, MyLong](MyInt(1), MyInt(2)).
  2. Allow multiple parameter lists for type parameters. There is already a SIP and some discussion. There are two big downsides to this approach. First, the definition site has to be edited with the knowledge that some parameters might be inferrable. Second, the “natural” order for type parameters might not be the necessary order for currying. IMHO it’s more natural to have def f[T][U](t: T): U than def f[U][T](t: T): U, but the latter would be necessary if you want to be able to do f[Long]('c').
  3. Allow specification of a prefix of type parameters. That is, given def f[U, T](t: T): U, one could invoke the function with foo[Long]('c') and have T be inferred. This seems slightly better than option 3 since the definition site only needs to be edited if the order needs to be changed and it also doesn’t require any new syntax in the language. I am unclear on the implementation details, but I suspect this is also relatively easy to implement in typer. There may be some hidden difficulties with overloads, I’m not sure.
  4. Allow specification of type parameters by name, and then allow the compiler to infer any unspecified arguments. It seems named type parameters were in dotty but then removed (according to this comment. This option requires new syntax in the language and inherits any implementation difficulties present in option 3, but has a lot of other upsides too.

IMHO option 4 is a nice option since the language change is minimal and it is forwards-compatible with option 5, which sounds like it may still be an option. I also like option 2 but it’s a bigger change and would complement option 4 in any case.

Thoughts? Any discussions I’m missing? Is this worth an SIP?

6 Likes

For option 3 vs option 4: I don’t think having the author have to update the definition site to allow setting the order is a terrible thing, for two reasons:

  1. It’s what we have to do now
    Though partial type application is annoying to implement, it is very possible to get the behavior today, and it’s currently dependent on the method definition
  2. The author is going to have to be aware of the utility of this anyway so the order allows for prefixes

It does also have the advantage of limiting this new behavior to locations where it is explicitly desired, which may be helpful if doing this generally is expensive in terms of resulting compilation times.

Theoretically option 5 is already implemented but available as an experimental feature Named Type Arguments.

So to be able to use it you need to be using a -SNAPSHOT or -NIGHTLY version of the compiler and explicitly enable the feature by either adding the -language:experimental.namedTypeArguments compiler flag or adding the import import language.experimental.namedTypeArgument.

And as it seems hardly anyone uses this feature it might still be not very well tested.

1 Like

I wasn’t aware that it was an experimental feature. I thought it was dropped completely.

As an aside, if you go to the source through the scaladoc you see the 2.13.7 source code instead of the patched scala 3 version.

Does that mean that option 4 is effectively available through the same path?

Not really. If you want to provide only some of type parameters, the parameters have to be named. If you use positional type parameters you can only either specify all of them or none.

But one nice way of achieving curried type parameters that wasn’t mentioned in this thread but could be useful in some cases is usage of extension methods where you can add type parameters both to the extension and to the method. E.g.

extension [I](a: I)
  def myPlus[O](b: I)(using Into[I, O]): O = ???

val x = MyInt(1).myPlus[MyLong](MyInt(2))
val y = myPlus(MyInt(1))[MyLong](MyInt(2))
4 Likes

Thanks. That’s a good point about using extension methods. That doesn’t quite fit my original (unwritten) motivation, which is to write nice DSLs. Having to change invocation syntax in DSLs may or may not defeat the point of the DSL.

To double-check: I can theoretically already invoke def foo[T, U](...) with foo[T = Int] where U will be inferred by the compiler, but not simply foo[Int]? That seems like a potentially easy change. Thanks for the pointer.

Yes, you can do that but you have to be aware of the limitations that usage of experimental features brings (especially if you’re designing a non-internal library nobody else will be able to use it in production).
And in your particular case I think the most elegant solution (and not requiring any usage of experimental language features) would be to slightly redesign your API so that one would rather write something like

myPlus(MyInt(1), MyInt(2)).as[Long]

instead of

myPlus[Long](MyInt(1), MyInt(2))

You could use partial application to effectively split your function into two function calls, one with inferred parameters and one without. See here (and others in that area)