Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Make RefinedTypeOps definition more ergonomic #253

Closed
Iltotore opened this issue Jul 30, 2024 · 11 comments · Fixed by #295
Closed

Make RefinedTypeOps definition more ergonomic #253

Iltotore opened this issue Jul 30, 2024 · 11 comments · Fixed by #295
Assignees
Labels
breaking changes Changes that break compatibility with older versions enhancement New feature or request
Milestone

Comments

@Iltotore
Copy link
Owner

Currently, declaring a new type requires to declare the constraint in the opaque type and in RefinedTypeOps's second argument which can be problematic for long constraints. Example derived from Valentin Bergeron and Raphaël Lemaitre's talk

opaque type Sats = Long :| GreaterEqual[0] & LessEqual[100000000 * 21000000]
object Sats extends RefinedTypeOps[Long, GreaterEqual[0] & LessEqual[100000000 * 21000000], Sats]

An alias can be used to mitigate the boilerplate:

private type SatsConstraint =
  GreaterEqual[0] & LessEqual[100000000 * 21000000]

opaque type Sats <: Long = Long :| SatsConstraint
object Sats extends RefinedTypeOps[Long, SatsConstraint, Sats]

Ideally, the redundancy should be removed either by passing only the third parameter in RefinedTypeOps:

opaque type Sats = Long :| GreaterEqual[0] & LessEqual[100000000 * 21000000]
object Sats extends RefinedTypeOps[Sats]

or defining the type alias from the companion object (like Neotype):

opaque type Sats = Sats.Type
object Sats extends RefinedTypeOps[Long, GreaterEqual[0] & LessEqual[100000000 * 21000000]]

I think defining the ops from the opaque type (first solution) is more natural. It was not possible before but might be doable easily enough since TypeRepr#dealias in Scala 3.4+ also dealiases opaque types. Thus, a mirror-like mechanism can (need to be tested) be used internally:

trait RefinedTypeOps[T](using mirror: IronType.Mirror, constraint: RuntimeConstraint[mirror.BaseType, mirror.ConstraintType])

On the other hand, solution 2 is pretty easy to do:

trait RefinedTypeOps[A, C]:

  type Type = A :| C

Users' opinions need to be collected in order to make a choice.

@Iltotore Iltotore added enhancement New feature or request breaking changes Changes that break compatibility with older versions labels Jul 30, 2024
@Iltotore Iltotore added this to the 3.0.0 milestone Jul 30, 2024
@vbergeron-ledger
Copy link
Contributor

I 👍 the solution from the opaque type definition is cleaner : more readable and the companion object fulfill more its role of, well, companion.

@Iltotore
Copy link
Owner Author

Iltotore commented Sep 1, 2024

Unfortunately the second solution does not seem doable at the moment. Scala is missing inline constructor parameters.

Since this feature will probably not land until long time, maybe it's worth to implement the first proposal instead?

What do you think @vbergeron-ledger?

@FrancisToth
Copy link
Contributor

Wouldn't be possible to use a match type for this?

opaque type Sats = Long :| GreaterEqual[0] & LessEqual[100000000 * 21000000]
object Sats extends NewType.Derive[Sats]
object NewType {
  type Derive[A] = A match
     case a :| c => RefinedTypeOps[a, c, A]
}

@Iltotore
Copy link
Owner Author

Iltotore commented Feb 4, 2025

It was actually the first design for RefinedTypeOps but it does not work correctly with opaque types once you escape their definition scope.

@Iltotore
Copy link
Owner Author

Iltotore commented Feb 7, 2025

So here is a list of thing I (re-)tried to make the following example work:

opaque type Temperature = Double :| Positive
object Temperature extends RefinedTypeOps[Temperature]
val temp = Temperature(5) //and getting an error when using a negative number

Match type

The most "vanilla" solution:

type Derive[T] = T match
  case IronType[a, c] => RefinedTypeOps[a, c, T]

This works, even for opaque types, until we try to use the newtype in another module.

val temp = Temperature(5)
object Temperature does not take parameters

Also tested with this alternative:

type ExtractBase[T] = T match
  case IronType[a, _] => a

type ExtractConstraint[T] = T match
  case IronType[_, c] => c

"Mirroring" the opaque type

The idea is to do something similar to RefinedTypeOps.Mirror but for the opaque type alias.

trait RefinedTypeOpsMeta[T]:
  type BaseType
  type ConstraintType

transparent inline given rtmeta[T]: RefinedTypeOpsMeta[T] = ${rtmetaImpl[T]}

def rtmetaImpl[T : Type](using Quotes): Expr[RefinedTypeOpsMeta[T]] =
  import quotes.reflect.*

  val ironType = TypeRepr.of[IronType]
  val tType = TypeRepr.of[T]

  tType.dealias match
    case AppliedType(ironType, List(baseType, constraintType)) =>
      type Base
      type Constr

      given Type[Base] = baseType.asType.asInstanceOf[Type[Base]]
      given Type[Constr] = constraintType.asType.asInstanceOf[Type[Constr]]

      //Instantiation of the `RefinedTypeOpsMeta` (too big for the example)

    case t =>
      report.errorAndAbort(s"Type does not seems to be an IronType: ${t.show(using Printer.TypeReprStructure)}")

With this approach, this work:

trait RefinedTypeOps[T]:
  val meta: RefinedTypeOpsMeta[T]
opaque type Temperature = Double :| Positive
object Temperature extends RefinedTypeOps[Temperature]:
  override val meta: RefinedTypeOpsMeta[Temperature] = rtmeta[Temperature]

But it is verbose and feel less natural than the other solutions.

The more ergonomic way would be to use something like:

trait RefinedTypeOps[T]:
  val meta: RefinedTypeOpsMeta[T] = rtmeta[T]

or

trait RefinedTypeOps[T](using meta: RefinedTypeOpsMeta[T])

but these two options do not work. The first one complains at compile-time that it does not know anything about T and in the second example, class/trait parameters cannot be inlined and the type information is lost.

@vbergeron-ledger
Copy link
Contributor

What about the following ?

object RefinedTypeOps:
  def fromMeta[T](meta: RefinedTypeOpsMeta[T]): RefinedTypeOps[T] = new RefinedTypeOps[T] {...}

opaque type Temperature = Double :| Positive
val Temperature = RefinedTypeOps.fromMeta[Temperature](rtmeta)

The usage will be the same, although methods are carried by a val.
I think implicit arguments can even lessen the burden here, but I need to test this.

@Iltotore
Copy link
Owner Author

Iltotore commented Feb 13, 2025

Looks good to me but how about object methods? We could use extensions methods but that would require importing them manually since AFAIK they are searched by the compiler like "normal functions":

opaque type Temperature = Double :| Positive
val Temperature = RefinedTypeOps.fromMeta[Temperature](rtmeta)

extension (a: Temperature)
  def +(b: Temperature): Temperature = Temperature(a+b)

There might also be a problem with given instances since (need to check) they would become orphan with this new pattern.

If so, I think the following way while being "less intuitive" is probably more ergonomic:

object Sats extends RefinedTypeOps[Long, GreaterEqual[0] & LessEqual[100000000 * 21000000]]
opaque type Sats = Sats.Type

I'll work on other tasks for 3.0.0 to let the discussion continue a bit.

@Iltotore
Copy link
Owner Author

This is the only issue remaining before releasing 3.0.0. I still think that in Scala's current state, the 2nd option (see example below) is better.

object Sats extends RefinedTypeOps[Long, GreaterEqual[0] & LessEqual[100000000 * 21000000]]
opaque type Sats = Sats.Type

I'm going to implement it.

@vbergeron-ledger
Copy link
Contributor

How about using type members in RefinedTypeOps to make the pattern more readable ? Something like this :

object Sats extends RefinedType:
  type Base = Long
  type Constraint = ???

I would also suggest to remove the Ops from the trait name, as it is more about declaring a refined type rather than adding operations to an existing opaque alias.

Finally, something we used in our internal framework before using iron was to call the inner type T instead of Type, which is shorter and somewhat allows to omit the second line for aliasing.

@Iltotore
Copy link
Owner Author

Iltotore commented Feb 23, 2025

So using

object Sats extends RefinedType:
  type Base = Long
  type Constraint = Foo

instead of

object Sats extends RefinedType[Long, Foo]

Finally, something we used in our internal framework before using iron was to call the inner type T instead of Type, which is shorter and somewhat allows to omit the second line for aliasing.

Why not and it stills allows those who prefer making the type alias (like me!) to do it. The only drawback is that it forbids making "transparent" new types but I don't think anyone actually uses this "feature".

I also think we should make opaque type T <: BaseType :| ConstraintType = ... in this case to still allows making newtypes being "subtypes" of their original type.

@Iltotore
Copy link
Owner Author

Iltotore commented Feb 23, 2025

Just realized that this definition:

object Sats extends RefinedType:
  type Base = Long
  type Constraint = Foo

is actually (AFAIK) not possible because of the required given RuntimeConstraint in the constructor that needs to now the Base type and Constraint.

Without it, the new design looks like this:

type Temperature = Temperature.T //Unnecessary if using Temperature.T instead.
object Temperature extends RefinedType[Double, Positive]

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
breaking changes Changes that break compatibility with older versions enhancement New feature or request
Projects
None yet
Development

Successfully merging a pull request may close this issue.

3 participants