You don’t need any compiler help to implement subtyping of labeled types!
I’ve played with all three. Here’s all you need. Change names as you prefer.
object NamedTypes {
/** A stable identifier to disambiguate types by label */
type Named = String & Singleton
/** A named type; create with `val x: Int \ "eel" = \(5)`; access with `x ~ "eel"`
* change to <: A = A or >: A = A for subtyping!
*/
opaque infix type \[+A, N <: Named] = A
object \ {
inline def apply[A, N <: Named](a: A): (A \ N) = a
extension [A, N <: Named](na: A \ N)
inline def ~(n: N): A = na
}
}
extension [A](a: A) {
/** Associate a compile-time name with this value by giving the other (Singular) value */
inline def \[N <: NamedTypes.Named](n: N): NamedTypes.\[A, N] = NamedTypes.\(a)
}
inline def literal[N <: NamedTypes.Named]: N = compiletime.constValue[N]
import NamedTypes.{ Named, \ }
The usage is pretty simple for singleton types.
val eels = 5 \ "eel" // type Int \ "eel"
val nEels = eels ~ "eel"
// val x = eels ~ "cod" does not compile
def recipe(l: Int \ "lemon", c: Int \ "cod"): Int \ "dinner" =
(l ~ "lemon" min 2*(c ~ "cod")) \ "dinner"
val food = recipe(5 \ "lemon", 3 \ "cod")
val notFood = recipe(4 \ "lemon", eels) // Fails
val whoKnows = recipe(6, 2) // Fails
val nFood: Int = recipe(5 \ "lemon", 3 \ "cod") // Fails
If you add subtyping in one direction or the other, some of the explicit naming of types with \
or extraction of values with ~
go away.
But I like it best like this because there is little reason to use this aside from safety. Regular types will catch anything that isn’t ambiguous, so who needs names? It’s only when the types don’t help but the identity really matters that this is important.
If you choose <: A = A
, then your use cases all get dangerous. In the case of function parameters, which are already named, no big deal. But what if you have
def recipe2(ingredients: (Int ~ "lemon", Int ~ "cod")): Int ~ "dinner" =
Nothing helps you use ingredients._1
and ingredients._2
properly. You can be sure you’re passing in lemons and cod, but within the method, all bets are off. Same deal if you destructure the ingredients; nothing requires you to get the labels right. In particular, if you change your interface and then need to fix it then things will silently be wrong.
>: A = A
is the other option. Your usage is now correct inside recipe2
, but you can pass in any old (5, 3)
. Is it an offset and length? Eels and more eels? Lemon and cod? Doesn’t matter! Again, if you change your interface and need to fix it, the compiler won’t help you.
So, for me, for named types, the right answer for correctness is clearly to have no subtyping relationship. (lemon = 5, cod = 3)
to me should be unrelated to (5, 3)
unless you call a conversion method. It should have accessors .lemon
and .cod
and that’s it. If we want to declare (5, 3)
to be (_1 = 5, _2 = 3)
that’s fine with me.
But the cool thing for me is that my flavor works out of the box already. I don’t need any compiler support to get things safe. If the compiler supports named tuples, and the named tuples don’t pull their weight, I don’t have to rely on them to get something like the feature. The syntax is a little bit more awkward. But it’s really not a big deal.