Multiple type parameter lists in Dotty? (SI-4719)

I recently ran into an situation where I wanted to provide some type arguments to a method manually, but have the others be inferred.

It was something like this

def retry[E <: Throwable: ClassTag,T](block: => T): T = {
...
}

Where basically I want to retry a certain block of code that will produce a type T in the event of an exception of type E. I want the type T to be inferred but I want to explicitly specify type E. I was able to solve this by wrapping the whole thing in a class which takes the type E as a parameter and defining retry as a method on that class,

    def forThrowable[E <: Throwable : ClassTag]: ForThrowable[E] = new ForThrowable[E]
    class ForThrowable[E <: Throwable : ClassTag] {
      def retry[T](block: => T): T = ...
    }

but it feels a bit unnecessary.

It seems being able to have multiple type parameter lists would solve this issue.

def retry[E <: Throwable: ClassTag][T](block: => T): T = {
...
}

There’s an SI for this issue as well https://issues.scala-lang.org/browse/SI-4719.

Are there currently any plans for this in Dotty? Thanks

1 Like

Dotty has (or will have) named type parameters which solve this particular usecase. Nontheless, multiple type parameters lists would probably make language more regular, so maybe worth investigating.

6 Likes

Also worth noting that Dotty already supports curried type lambdas, as in:

scala> type M = [A] => [B] => Map[A,B]
// defined alias type M = [A] => [B] => Map[A, B]
scala> Option.empty[M[Int][String]]
val res0: Option[M[Int][String]] = None

So it would make sense to allow multiple parameter lists in types with the same meaning:

type M[A][B] = Map[A,B]

…and to generalize that syntax to methods.

11 Likes

Named type parameters can be used for this, but they’re not the ideal solution imho. They require the caller of the method to know the names of the type parameters and to know which type parameters are supposed to be inferred and which are not. Plus it’s pretty verbose at the call site.

3 Likes

You can always define a type with names that you like, i.e. write something like

type M[KeyILike, ValueILike] = Map[KeyILike, ValueILike]

Thus, you can define names you like if it is appropriate for you to define your own type-aliases.

Do you think it is a deal of the declaration site to define the order of the several type parameter lists? I.e., for example, imagine that Map is defined with (potential) multi-type-parameter lists as

trait Map[K][V] ...

and we can use Map[Int] to forge V be inferred.

Those who need V be inferred are happy, but those two need K be inferred would need to define something like

type M[V][K] = Map[K, V]

to have the K type parameter at the end.
I think this type of solution is not better than defining type-aliases with known name (like above) or ad-hoc type lambdas.


Personally, I think that type lambdas-based stuff should somehow solve it, like maybe using

[T] => retry { ... }

at the call-site of the topicstarter’s example. I think so because it seems that user-site knows which type parameters should be inferred and which not rather than the definition-site.

This is about methods where I think type aliases have little relevance. Indeed class types like Map may be a better fit for named type parameters. In my experience for methods it are almost always the same parameters that should be explicitly provided. E.g. in the example given in the OP it doesn’t make any sense to explicitly provide T and let E be inferred (it would be Nothing…).

1 Like

I agree @Jasper-M. Also it seems from SI-4719 that another benefit of multiple type parameter lists would be to help with implicit resolution, especially for code doing a lot of type-level computations. @LPTK, would using using the approach you described confer this benefit as well (being able to solve implicits per type param list), or would it just work for the example I posted?

To be clear, I didn’t describe any approach. I just meant to show that it would make sense to have multiple type parameter lists for both types and methods, from the point of view of language consistency.

Oh I see

Ran into a situation today where this would have been really useful when defining extension methods for a typeclass.

Assuming something like this (a slice of Applicative):

trait Pure[C[_]]
  def pure[A](a: A): C[A]

object Pure
  def apply[C[_]](given P: Pure[C]) = P

  given Pure[Option]
    def pure[A](a: A): Option[A] = Some(a)

If you want to enable syntax like this:

1.pure[Option]

You’d need to do something like this (which is quite a lot of ceremony, the error messages tend to be arcane, and you need to import scala.language.implicitConversions at all the call sites):

import scala.language.implicitConversions
given liftToPurePartiallyApplied[A]: Conversion[A, PurePartiallyApplied[A]] = 
  a => new PurePartiallyApplied[A](a)

final class PurePartiallyApplied[A](a: A) extends AnyVal
  def pure[C[_]](given Pure[C]) = Pure[C].pure(a)

If you could define multiple type parameter lists this could be simplified considerably:

def [C[_]][A] (a:A) pure (given Pure[C]): C[A] = Pure[C].pure(a)

Allowing the additional type parameter lists to be inserted between regular parameter lists would increase the readability further:

def [A] (a:A) pure [C[_]](given Pure[C]): C[A] = Pure[C].pure(a)
1 Like

Turns out I was wrong, there’s a way to do it, but it’s verbose and the possibility of doing something like this is implied in the documentation, but not outright stated.

Hopefully, this doesn’t work by accident.

trait PureLifts[A]
    def[C[_]](a: A) pure (given C: Pure[C]): C[A] = C.pure(a)

given lifts[A]: PureLifts[A]

Still needs an import, but I’m ok with an import to enable syntax injection:

import Pure.lifts

1.pure[Option]

I’m not really sure why it doesn’t need to be import Pure.{given lifts}, based on how it’s defined, but as long as it works …

1 Like

When you write 1.pure[Option], the compiler doesn’t find a pure operation on type Int, so it starts looking for an extension method. It first searches in the enclosing scope (inherited members, definitions of an outer lexical scope, and finally imports). If you omit the import Pure.given clause, the compiler will not find it in the enclosing scope and will fallback to the search scope made of given definitions in companion objects. Which companion objects will be looked at? In your example, since you are trying to call a method on an Int value, only the Int companion object will be inspected, but your lifts definition is not there. The compiler will not look into your entire classpath to find whether there is a given extension method that could be applied to Int. That’s why you have to import it. I hope it clarifies!

I get why there needs to be an import, as I noted in the original post:

What I was unclear on at the time of the original post was the difference between these:

import Pure.{given, lifts}
import Pure.{given lifts}

The first works, though the second element is redundant. The second version does not work.

“For lack of a comma, the compiler was lost”

The first line imports all given definitions, as well as the lifts definition (which has already been included by the import Pure.given part, so it should be necessary).

The second line imports all given definitions of type lifts. It should be a compilation error since there is no type lifts. Please report the bug if there is no compilation error.

If you want to use a selective given import, the following should work:

import Pure.{given PureLifts[?]}

So an import Pure._ will import all symbols except lifts, because lifts is a given?

Yes, see the documentation: https://dotty.epfl.ch/docs/reference/contextual/import-delegate.html

Any news on if this is a “won’t do” or simply never gained traction?

The use-case for this is at least common enough for it to make it into the Cats guidelines under “Partially-Applied Type”

4 Likes

We ran out of time to get it in for 3.0. It would be still quite desirable to do it in some later release, but it will require a fair amount of effort.

4 Likes

Thanks for the update :+1:

Just FYI for someone who stumbles on this: I believe this is the shortest version we can have so far:

object Syntax:
  given [A] as AnyRef:
    def[F[_]: Applicative](a: A).pure: F[A] =
      summon[Applicative[F]].pure(a)

and then

import Syntax.{ given AnyRef, _ }
// or
import Syntax.{ given _, _ }

Hopefully at some point in the future this will be enough:

def[A][F[_]: Applicative](a: A).pure: F[A] =
  summon[Applicative[F]].pure(a)
1 Like