Opaque Types vs AnyVal Wrappers for a Cross-Built Scala Library
0 reputation · 12 Nov 2024, 22:33 UTC
0 reputation · 12 Nov 2024, 22:33 UTC
A library that cross-builds for Scala 2.13 and Scala 3 needs a zero-cost wrapper for identifiers in a hot path, while keeping the public API distinct from the underlying type. Two documented options are Scala 3 opaque type aliases and value classes that extend AnyVal. Opaque types hide their representation outside the defining scope and are erased to the underlying type, but they are unavailable in Scala 2.13. Value classes are available on both compiler lines, yet the compiler may box them in generic, array, or pattern-matching contexts, so allocation-free behavior is not guaranteed by the declaration alone.
The unresolved choice is whether to use AnyVal wrappers across both artifacts and accept possible boxing, or expose opaque types only in the Scala 3 artifact and maintain a separate Scala 2.13 representation. Equality, serialization, and library codecs also differ because opaque types require explicit extension methods or given instances, while value-class members are declared directly.
For a cross-built library with a hot-path identifier, which approach better balances API type safety and predictable allocation? Should the Scala 2.13 and Scala 3 artifacts share one wrapper strategy or diverge? What evidence is sufficient to treat either wrapper as allocation-free in a specific usage context?
For a library where a hot-path identifier must be allocation-free, you should diverge the implementation strategy. Use opaque type aliases for the Scala 3 artifact and AnyVal value classes for the Scala 2.13 artifact. While this increases maintenance, it is the only way to guarantee zero-cost erasure in Scala 3 while maintaining a compatible (though potentially boxing) wrapper in Scala 2.13.
Using a single AnyVal strategy across both versions ensures a unified API, but it introduces unpredictable boxing. In a hot path, AnyVal wrappers often box when passed to generic methods (e.g., List[UserId]) or stored in arrays. Scala 3's opaque types solve this by erasing completely to the underlying type at runtime, regardless of the context, provided the representation is not leaked.
To maintain a consistent public API while diverging the internal representation, use a common trait or a version-specific module pattern:
opaque type UserId = String. Use extension methods to provide the necessary API. This ensures the JVM sees only a java.lang.String.case class UserId(value: String) extends AnyVal. Implement members directly on the class.Neither declaration alone guarantees zero allocations. To treat a wrapper as truly allocation-free in a specific hot-path context, you must provide the following evidence:
javap -c on the compiled class files. Look for invokevirtual or invokespecial calls to the wrapper's constructor in the hot-path method. If you see new instructions, the wrapper is boxing.-Xprint:phases to verify that the opaque type is erased to the underlying primitive/type before the final bytecode generation.-prof gc. A truly zero-cost wrapper will show 0 bytes allocated per operation in the hot path.To refine this recommendation: Are the identifiers primarily passed to generic collections (e.g., Seq[T]) or used in primitive-like arrays? If they are strictly used in non-generic, specialized methods, AnyVal may suffice for both versions, removing the need to diverge.
Use comments to ask for clarification. Post a solution as an answer.
No question comments on this page.