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?

7 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.

3 Likes

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))
6 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.

1 Like

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)

I was hopeful that the clause intervweaving SIP was going to be an answer for this problem, but it has been amended so that it is not possible to specify multiple consecutive type parameter lists. IMHO, the all-or-none nature of type parameters is a major headache for Scala, and one where Rust is strictly better. Is there any strong opposition to Option 2 in my proposal here (using _ or ? to in type argument lists to indicate “infer me” type parameters)?

1 Like

I recently discovered that extension methods can do what you want.

trait F[A,B]

given F[Int,Float]


extension [A,B,C](ab: Either[A,B])
  def get[D](using F[D,C]): C = ???

val ab: Either[String, Char] =???
ab.get[Int] //return type Float

Thanks, someone else had already pointed that out earlier and I responded. Extension methods require dot-call syntax and also require the library writer to know which arguments the user might specify instead of inferring up front. A type-level equivalent of summon/implicitly (as Rust has) has neither of those limitations.

I think named type arguments will be the idiomatic solution for that. Named type arguments increase readability whereas the alternatives (i.e. some wildcard marker, or curried type arguments) decrease it. So we should go all in with this, and do it soon. I am in train of improving the proposal in the link to allow named type arguments everywhere, not just in method calls.

7 Likes

I see. I both disagree that named typed arguments are more readable and also don’t understand why named type arguments, curried type arguments, and wildcard markers need to compete. IMHO, options 2-5 are all available for regular parameters, so why not make them available for type parameters?

However, I see that the ship is likely about to set sail on this one so I’ll just wait patiently for named type arguments!

I expect named type arguments are going to be extremely annoying to use in practice. One of the primary uses I’ve seen in the wild for type currying is specifying an effect type, and type currying works extremely well for situations like this.

Assuming a method with a fairly simple signature for something like this: def foo[F[_], A](fa: F[A]): F[A]

Type currying or multiple adjacent type parameters work nicely because the kind of the types that can be inferred and those that cannot are different, so it’s very hard to screw up something like foo[EitherNel[String,*]](fa).

Named type arguments just adds cognitive load on the user, because now you have to remember what the library maintainer chose as the argument type name. For example, foo[F = EitherNel[String,*]](fa) vs foo[FE = EitherNel[String,*]](fa).

It also converts changes to type argument names from source-compatible to source-incompatible.

Proponents of named type arguments have argued that this just means library authors will need to choose more descriptive type names, which is strictly worse. In this scenario, not only does the user have to deal with remembering what the author chose, they have to deal with typing the thing out: foo[EffectError = EitherNel[String,*]](fa).

Some of this is going to be alleviated by tooling improvements, but why go with something that’s more annoying (but can be made less so) than enabling something which already works, people are already familiar with, and is already supported by the tooling?

I also broadly agree that named type parameters are orthogonal to type parameter partial application. Term parameters come in both named or positional variants, either of which can be fully applied, partially applied (via _), or fully inferred (via implicit) or partially inferred (via implicitly). I use all permutations of these on a regular basis

I don’t see any reason why the same use cases, ergonomics, and affordances wouldn’t apply to type parameters as well. We already have fully applied type parameters, partially applied type parameters (via _), fully inferred type parameters. That proposal adds named type parameters, and it would be a nice synmetry to have both named and positional partially inferred type parameters, symmetric to how implicitly gives us partially inferred term parameters

3 Likes

It’s a cultural problem, not a technical one. So far, there was no way to allow partial type inference (except for the obscure Helper-with-apply method pattern). Now we have two candidates. Both require some degree of buy in.

Curried type parameters require changing the API to support them and worrying about the order in which type parameters should be given. I note that this will not always be the most readable order. As an example, let me just point to the Either debacle. Many languages have a usable result type where of course the result comes first (eg. std::Result in Rust). Scala has Either where the order of parameters is swapped. This is a major roadblock to understanding! Everybody who says different is suffering from expert- (not to say Stockholm-) syndrome. Where did it come from? It came from Haskell’s Either where by convention the result is also written second. Why was that? Because it was deemed that it would be better for specifying the error type first while still allowing the result type to vary (in inference or constructors). So curried type parameters have already contributed to one of the major library design fails in Scala even though they don’t exist yet! And if we make them official no doubt more fails will follow, since the cultural influence from Haskell is very strong.

Named type parameters don’t require the same tradeoffs; they work out of the box with existing architectures. Over time, they will influence API design in the direction that more descriptive names of type parameters will be preferred. I believe this is unequivocally a good thing.

Unlike curried type parameters, named type parameters will also work for constructors, so they are more orthogonal.

I am not against opening up curried type parameters at some point (since at the moment, once we admit type parameter interleaving, it actually takes work to prevent them), but only after named type parameters are firmly established as the default choice for partial inference. Before it’s just not safe. It would split the library ecosystem with a large portion of libraries going in a direction that would make Scala more obscure and difficult to read.

6 Likes

Can the project to unify type parameters and type members not be revived? This was originally the lead feature for Scala 3.0. Even if we can’t move to immediate unification I think any changes should be made in that context. And that unification justifies breaking changes.

1 Like

Sorry if I’m not following, but would there be any problem in Scala if the result were on the left? If you could specify a prefix of type arguments, I think you would do

sealed abstract class Either[+T, +E] 
final case class Left[+T, +E](value: E) extends Either[T, E]
final case class Right[+E, +T](value: T) extends Either[T, E]

where the only confusing thing would be the swapped arguments on Right, which people would rarely notice. Similarly, with currying you would do

sealed abstract class Either[+T, +E] 
final case class Left[+T][+E](value: E) extends Either[T, E]
final case class Right[+E][+T](value: T) extends Either[T, E]

Can you say more about that (sorry if it’s been said elsewhere). Term parameters can be curried in constructors, so why not type parameters?

1 Like

This is actually something I didn’t consider! When I imagine partial type parameter inference, i was thinking something like this:

val foo = Right[String, Implicitly](123) // Right[String, Int]

where Implicitly is a magic type analoguous to the implicitly method we use for term parameters. The name can be tweaked, but jt would provide a way to partially infer type parameters without currying or named params

1 Like