Introduction
Scala 3 supports various type-level operations via compiletime.ops.*
. The great thing about them is that they compose well (e.g. val i : 1 + 2 * 3 = 7
), as we expect from their equivalent term-level operations. But we have two problems:
- What do we do when an operation we want isn’t supported?
- What do we do when we have a new custom type that we want to have type-level operations on?
There is a limit how much we can hard-code into the compiler, so how do we achieve the required flexibility?
Using Implicits
Here is a simple example, just for understanding the concept.
trait MyType:
type Out <: Int
trait One extends MyType:
type Out = 1
trait Two extends MyType:
type Out = 2
trait Plus[A <: MyType, B <: MyType] extends MyType
type Out <: Int
transparent inline given [A <: MyType, B <: MyType] : Plus[A, B] = ${someMacro}
If we don’t have internal support for the literal type addition, then we need to use a macro, but the macro is too complicated and requires to traverse all the compositions to evaluate the type expression and set the proper Out
:
summon[One Plus Two Plus One]
All of this traversal and evaluation mechanism already exists within the compiler when it executes the compiletime.ops
evaluation. So how can we utilize it to our benefit for our custom types and their operations?
Custom dependent operation types
To solve the problem, we introduce the following API:
package compiletime.ops
final class customOp extends StaticAnnotation
erased trait CustomOp[T <: AnyKind, Args <: Tuple]:
type Out
The compiler’s job is fairly simple. Whenever it evaluates a type T[A1, A2, A3, ...]
with a customOp
annotation it searches for the implicit CustomOp[T, (A1, A2, A3, ...)]
, after it evaluates all type arguments of T
is the same fashion. So the compiler evaluates the composition of all customOp
types (and internally supported types) out-of-the-box, and we only need to define the implicits.
Thus our example will change to the following:
import compiletime.ops.{customOp, CustomOp}
@customOp type One <: Int
given CustomOp[One, ()] with
type Out = 1
@customOp type Two <: Int
given CustomOp[Two, ()] with
type Out = 2
@customOp type Plus[A <: Int, B <: Int] <: Int
transparent inline given [A <: Int, B <: Int]: CustomOp[Plus, (A, B)] = ${simplerMacro}
Another great advantage is that instead of dragging our dependent type with Out
, we get the value directly:
val x : One Plus Two Plus One = 4
Would love to get your feedback