Opaque type treated as underlying type in some scenarios
Hi! I was trying to use the library for a work project but found an apparent shortcoming that makes it a little bit tricky.
Consider this example:
//> using dep io.github.arainko::ducktape::0.2.4
import io.github.arainko.ducktape.*
case class From(name: String)
object scoped {
object types {
opaque type Ident = String
}
export types.Ident
case class To(name: String, id: Ident)
}
object demo {
def f(
f: From,
theId: scoped.Ident,
): scoped.To = f.into[scoped.To].transform(Field.const(_.id, theId))
}
Everything is alright here. Now, move opaque type Ident one level up so that To actually sees that it's a string:
- object types {
- opaque type Ident = String
-}
- export types.Ident
+ opaque type Ident = String
Now, f fails to compile:
[error] ./main.scala:20:46
[error] Configuration is not valid since the provided type (scoped.Ident) is not a subtype of java.lang.String @ To.id
[error] ): scoped.To = f.into[scoped.To].transform(Field.const(_.id, theId))
[error] ^^^^^^^^^^^^^^^^^^^^^^^^
[error] ./main.scala:20:18
[error] No field 'id' found in From @ To.id
Originally, my code looked like the broken version, I can probably work around this by moving / wrapping the opaque types, but this seems like something that the library could improve upon. :)
Hey! Thanks for the report - yeah I'd fully expect it to work... Maybe I went a .dealias too far somewhere down the line, I'll investigate ASAP
So, I'm not able to repro this on Scala 3.3.3 (running right from the ducktape repo), i.e. this:
case class From(name: String)
object scoped {
opaque type Ident = String
case class To(name: String, id: Ident)
def f(
f: From,
theId: scoped.Ident
): scoped.To = f.into[scoped.To].transform(Field.const(_.id, theId))
}
object demo {
def f(
f: From,
theId: scoped.Ident
): scoped.To = f.into[scoped.To].transform(Field.const(_.id, theId))
}
@main def main = {
println(scoped.f(From("a"), "ident".asInstanceOf[scoped.Ident]))
println(demo.f(From("a"), "ident".asInstanceOf[scoped.Ident]))
}
compiles and works exactly as I'd expect:
[info] running io.github.arainko.ducktape.main
To(a,ident)
To(a,ident)
[success] Total time: 0 s, completed Aug 22, 2024, 9:02:12 PM
I'll try running from a project that pulls the dep in and across different Scalas, maybe that's a factor for some reason...
Hah, pulling the dep in from a separate project reproduces it, weird
full repro:
//> using scala 3.3.3
//> using repositories sonatype-s01:snapshots
//> using dep io.github.arainko::ducktape::0.2.4-16-8bf2679-20240822T175302Z-SNAPSHOT
import io.github.arainko.ducktape.*
case class From(name: String)
object scoped {
opaque type Ident = String
case class To(name: String, id: Ident)
// def f(
// f: From,
// theId: scoped.Ident
// ): scoped.To = f.into[scoped.To].transform(Field.const(_.id, theId))
}
object demo {
def f(
f: From,
theId: scoped.Ident
): scoped.To =
f
.into[scoped.To]
.transform(Field.const(_.id, theId))
}
@main def main = {
// println(scoped.f(From("a"), "ident".asInstanceOf[scoped.Ident]))
println(demo.f(From("a"), "ident".asInstanceOf[scoped.Ident]))
}
Logs:
aleksander@pop-os:~/repos/repro-196$ scala-cli compile . --server=false
-- [E008] Not Found Error: /home/aleksander/repos/repro-196/repro.scala:34:17 --
34 | println(scoped.f(From("a"), "ident".asInstanceOf[scoped.Ident]))
| ^^^^^^^^
| value f is not a member of object scoped
1 error found
Compilation failed
aleksander@pop-os:~/repos/repro-196$ scala-cli compile . --server=false
[INFO] [repro.scala:17:24] Structure: Product(
tpe = Type.of[From],
path = From,
fields = Map(Entry(key = "name", value = Lazy(tpe = Type.of[String], path = From.name)))
)
[INFO] [repro.scala:17:24] Structure: Product(
tpe = Type.of[To],
path = To,
fields = Map(
Entry(key = "name", value = Lazy(tpe = Type.of[String], path = To.name)),
Entry(key = "id", value = Lazy(tpe = Type.of[String], path = To.id))
)
)
[INFO] [repro.scala:17:24] Structure: Ordinary(tpe = Type.of[String], path = From.name)
[INFO] [repro.scala:17:24] Structure: Ordinary(tpe = Type.of[String], path = To.name)
[INFO] [repro.scala:17:24] Structure: Ordinary(tpe = Type.of[Nothing], path = From)
[INFO] [repro.scala:17:24] Parsed path: To.id
[INFO] [repro.scala:17:24] Original plan: BetweenProducts(
source = Product(
tpe = Type.of[From],
path = From,
fields = Map(Entry(key = "name", value = Lazy(tpe = Type.of[String], path = From.name)))
),
dest = Product(
tpe = Type.of[To],
path = To,
fields = Map(
Entry(key = "name", value = Lazy(tpe = Type.of[String], path = To.name)),
Entry(key = "id", value = Lazy(tpe = Type.of[String], path = To.id))
)
),
fieldPlans = Map(
Entry(
key = "name",
value = Upcast(
source = Ordinary(tpe = Type.of[String], path = From.name),
dest = Ordinary(tpe = Type.of[String], path = To.name)
)
),
Entry(
key = "id",
value = Error(
source = Ordinary(tpe = Type.of[Nothing], path = From),
dest = Lazy(tpe = Type.of[String], path = To.id),
message = NoFieldFound(fieldName = "id", fieldTpe = Type.of[String], sourceTpe = Type.of[From]),
suppressed = None
)
)
)
)
[INFO] [repro.scala:17:24] Config: List(
Static(
path = To.id,
side = Dest(),
config = Const(value = Expr[theId], tpe = Type.of[Ident]),
span = Span(start = 569, end = 593)
)
)
[INFO] [repro.scala:17:24] Reconfigured plan: Reconfigured(
errors = List(
Error(
source = Ordinary(tpe = Type.of[Nothing], path = From),
dest = Lazy(tpe = Type.of[String], path = To.id),
message = InvalidConfiguration(
configTpe = Type.of[Ident],
expectedTpe = Type.of[String],
side = Dest(),
span = Span(start = 569, end = 593)
),
suppressed = None
)
),
result = BetweenProducts(
source = Product(
tpe = Type.of[From],
path = From,
fields = Map(Entry(key = "name", value = Lazy(tpe = Type.of[String], path = From.name)))
),
dest = Product(
tpe = Type.of[To],
path = To,
fields = Map(
Entry(key = "name", value = Lazy(tpe = Type.of[String], path = To.name)),
Entry(key = "id", value = Lazy(tpe = Type.of[String], path = To.id))
)
),
fieldPlans = Map(
Entry(
key = "name",
value = Upcast(
source = Ordinary(tpe = Type.of[String], path = From.name),
dest = Ordinary(tpe = Type.of[String], path = To.name)
)
),
Entry(
key = "id",
value = Error(
source = Ordinary(tpe = Type.of[Nothing], path = From),
dest = Lazy(tpe = Type.of[String], path = To.id),
message = InvalidConfiguration(
configTpe = Type.of[Ident],
expectedTpe = Type.of[String],
side = Dest(),
span = Span(start = 569, end = 593)
),
suppressed = None
)
)
)
)
)
-- Error: /home/aleksander/repos/repro-196/repro.scala:27:4 --------------------
27 | f
| ^
| No field 'id' found in From @ To.id
|----------------------------------------------------------------------------
|Inline stack trace
|- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
|This location contains code that was inlined from repro.scala:27
----------------------------------------------------------------------------
-- Error: /home/aleksander/repos/repro-196/repro.scala:29:17 -------------------
29 | .transform(Field.const(_.id, theId))
| ^^^^^^^^^^^^^^^^^^^^^^^^
|Configuration is not valid since the provided type (scoped.Ident) is not a subtype of java.lang.String @ To.id
|----------------------------------------------------------------------------
|Inline stack trace
|- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
|This location contains code that was inlined from repro.scala:29
----------------------------------------------------------------------------
2 errors found
Compilation failed
I'm pretty sure that this is some kind of an incremental compilation bug, it randomly disappears and reappears with no actual code changes (i.e. it fails after adding a newline but disappears after a clean;compile)...
Reported this under https://github.com/scala/scala3/issues/21430