I generally like to use the following pattern -
final class ProductId(val underlying: String) extends AnyVal
This has the following benefits -
- You can access the
underlying
value whenever you need
- Prevent pattern matching on the constructor by not using a
case class
, which helps to avoid actually constructing the object so the runtime value stays as a String
(or whatever your underlying value is)
- You can create a smart constructor using a companion object to validate its input and provide a cleaner interface when constructing the value so the user doesn't have to use
new
.
Example of a smart constructor below -
final class ProductId(val underlying: String) extends AnyVal
object ProductId {
def apply(s: String): Result = {
if (s.isEmpty) {
new Failure("ProductId cannot be empty!")
} else {
new Success(new ProductId(s))
}
}
sealed trait Result
final case class Success(productId: ProductId) extends Result
final case class Failure(message: String) extends Result
}
If you want to ensure that users must use the smart constructor, mark your value class' constructor as private -
final class ProductId private (val underlying: String) extends AnyVal
If you want to ensure that you're not accidentally allocating an instance of ProductId
, you can check the bytecode -
scala> :paste
class Test {
def testProductId = new ProductId("foo")
def testSmartCtor = ProductId("bar") match {
case ProductId.Success(productId) => productId
case ProductId.Failure(message) => throw new AssertionError(message)
}
}
// Ctrl+D
:javap -c ProductId$
// Skipping to the apply() method
public ProductId$Result apply(java.lang.String);
Code:
0: aload_1
1: invokevirtual #20 // Method java/lang/String.isEmpty:()Z
4: ifeq 19
7: new #22 // class ProductId$Failure
10: dup
11: ldc #24 // String ProductId cannot be empty!
13: invokespecial #27 // Method ProductId$Failure."<init>":(Ljava/lang/String;)V
16: goto 27
19: new #29 // class ProductId$Success
22: dup
23: aload_1
24: invokespecial #30 // Method ProductId$Success."<init>":(Ljava/lang/String;)V
27: areturn
There are no new ProductId
references in the bytecode, so at runtime your ProductId
will be represented as a String
.
Note that if you try to wrap a value class in a class which uses generics (e.g. Option, Either) then your value will get boxed. You can avoid this by creating a simple case class which is specialized for your value class. While the case class will get instantiated (since you can't wrap a value class with another value class) the underlying ProductId
will still be represented as String
at runtime.