More Types
More Types
Although we use strongly typed language, we often write Stringly Typed
code or rely too much on base value types like Double
, Float
, Long
, Int
, Short
, Byte
, Char
, and Boolean
.
Relying Too Much on Base Value Types
The Bad and The Ugly
- Typical case class with base value types and String.
final case class User(
id: Long,
firstName: String,
lastName: String,
email: String,
enabled: Boolean
)
val id = 1L
// id: Long = 1L
val firstName = "Kevin"
// firstName: String = "Kevin"
val lastName = "Lee"
// lastName: String = "Lee"
val email = "kevin@blah.blah"
// email: String = "kevin@blah.blah"
// by mistake the order of arguments messed up.
val user =
User(
id,
email,
firstName,
lastName,
true // This is also vague. What does true mean? is enabled? is disabled?
)
// user: User = User(
// id = 1L,
// firstName = "kevin@blah.blah",
// lastName = "Kevin",
// email = "Lee",
// enabled = true
// )
There could be another problem with Boolean
. Over time, the requirement may change, and now enabled
should be changed to disabled
. Once you change it, the meaning of the field is just the opposite of the old one, so your code having true
for that field is all wrong. It would be hard to fix all of them.
The Good
Use Value Classes and ADTs
Scala has a good solution for the issue from relying too much on base value types. That is Value Class. So a new type can be defined without any runtime-cost as there is only internal type but no wrapper type after the code gets compiled.
For Boolean
, it is much better to use ADTs than just Boolean
. In the case class example above, the field enabled
can be re-written as an ADT.
So a possible solution for the first issue might be
final case class User(
id: User.Id, // maybe just id: Id after import User._
firstName: User.FirstName,
lastName: User.LastName,
email: User.Email,
accountStatus: User.AccountStatus
)
// Depends on how common the fields types are, you probably don't want to
// define them inside User object
object User {
final case class Id(id: Long) extends AnyVal
final case class FirstName(firstName: String) extends AnyVal
final case class LastName(lastName: String) extends AnyVal
final case class Email(email: String) extends AnyVal
sealed trait AccountStatus
object AccountStatus {
case object Enabled extends AccountStatus
case object Disabled extends AccountStatus
def enabled: AccountStatus = Enabled
def disabled: AccountStatus = Disabled
}
}
val id = User.Id(1L)
// id: User.Id = Id(id = 1L)
val firstName = User.FirstName("Kevin")
// firstName: User.FirstName = FirstName(firstName = "Kevin")
val lastName = User.LastName("Lee")
// lastName: User.LastName = LastName(lastName = "Lee")
val email = User.Email("kevin@blah.blah")
// email: User.Email = Email(email = "kevin@blah.blah")
// The order of the constructor arguments is messed up again
// but this time, it does not compile.
val user =
User(
id,
email,
firstName,
lastName,
User.AccountStatus.enabled
)
// error: type mismatch;
// found : repl.MdocSession.MdocApp1.User.Email
// required: repl.MdocSession.MdocApp1.User.FirstName
// email,
// ^^^^^
// error: type mismatch;
// found : repl.MdocSession.MdocApp1.User.FirstName
// required: repl.MdocSession.MdocApp1.User.LastName
// firstName,
// ^^^^^^^^^
// error: type mismatch;
// found : repl.MdocSession.MdocApp1.User.LastName
// required: repl.MdocSession.MdocApp1.User.Email
// lastName,
// ^^^^^^^^
// Now the order of the constructor arguments is all correct.
val user =
User(
id,
firstName,
lastName,
email,
User.AccountStatus.enabled
)
// user: User = User(
// id = Id(id = 1L),
// firstName = FirstName(firstName = "Kevin"),
// lastName = LastName(lastName = "Lee"),
// email = Email(email = "kevin@blah.blah"),
// accountStatus = Enabled
// )
The new name for enabled
is now accountStatus
, but even having a field name like enabled
or disabled
and swapping them doesn't change the meaning of AccountStatus.Enabled
and AccountStatus.Disabled
, so you can switch the names without any concern if you use ADT instead of Boolean
.
Better - Use newtype
newtype is a Scala's way
to have newtype
in Haskell.
It's similar to Scala's value class.
In Scala 3 (currently Dotty), it's a language feature called opaque type
.
- Scala 2 - newtype
- Scala 2 - value class
- Scala 3 - opaque type
import io.estatico.newtype.macros.newtype
object Types {
@newtype case class Name(name: String)
}
import Types._
def foo(name: Name): Unit = println(name)
Name("Kevin")
// res4: Name = "Kevin"
println(Name("Kevin").getClass)
// class java.lang.String
foo(Name("Kevin"))
// Kevin
object MyTypes {
final case class Name(name: String) extends AnyVal
}
import MyTypes._
def foo(name: Name): Unit = println(name)
Name("Kevin")
// res8: Name = Name(name = "Kevin")
println(Name("Kevin").getClass)
// class repl.MdocSession$MdocApp7$MyTypes$Name
foo(Name("Kevin"))
// Name(Kevin)
object Types {
type Name = Name.Name
object Name {
opaque type Name = String
def apply(name: String): Name = name
extension (name: Name) {
def value: String = name
}
}
}
import Types._
def foo(name: Name): Unit = println(name)
Name("Kevin")
// Name.Name = Kevin
Name("Kevin").value
// String = Kevin
Name("Kevin").getClass
// class java.lang.String
foo(Name("Kevin"))
// Kevin
When creating a newtype for primitive types and you want them to be primitive types like int
, boolean
and double
instead of boxed-primitive like Integer
, Boolean
and Double
in runtime, use @newsubtype
.
import io.estatico.newtype.macros.{newtype, newsubtype}
object Types {
@newtype case class BoxedNum(n: Int)
@newsubtype case class Num(n: Int)
}
import Types._
object MyApp {
val a = BoxedNum(1)
val b = Num(2)
}
$ javap kevinlee.MyApp
Compiled from "MyApp.scala"
public final class kevinlee.MyApp {
public static int b();
public static java.lang.Object a();
}
Error Handling
The Bad and The Ugly
Throwing Stringly-typed Exception
- The ugly Stringly-typed error handling
final case class InvalidInputException(message: String) extends RuntimeException(message)
def foo(n: Int): Int =
if (n < 0)
throw InvalidInputException(s"Invalid number: $n")
else
n * 2
def bar(name: String): String =
if (name.isEmpty)
throw InvalidInputException(s"Name should not be empty")
else
s"Hello $name"
def baz(n: Int, name: String): String = {
val number = foo(n)
val greeting = bar(name)
s"$greeting - your number is $number"
}
val n = -1
// n: Int = -1
val name = "Kevin"
// name: String = "Kevin"
try {
baz(n, name)
} catch {
// There is nothing much you can do about it. (log? re-throw?)
// THe only type you have is String so you can't distinguish
// invalid number in foo from an empty input in bar as the only
// type you have is String.
// Scala is a Strongly-Typed language but you are using it as
// a Stringly-Typed language.
case InvalidInputException(message) =>
println(message)
}
// Invalid number: -1
// res12: Any = ()
The Good
More Types for Error Handling
Throwing an exception is bad as you can't even guess how many cases may throw exceptions so it is so hard to reason about. It would be good if a total function can be written so that you don't need to worry about the exception. Yet if it's not possible, instead of writting a partial function and let an exception be thrown, use more types to handle it properly.
- Total Function: A function defined for all possible values for the input type (i.e. for all of the domain).
- Partial Function: A function defined for some values of the input type (i.e. for some of the domain) meaning that there might be some values of the right input type that can't be handled by the function as it's not defined for those values. (e.g.
(a: Int, b: Int) => a / b
where0
is an input value for the right type of the second paramb
yet it is not a valid value forb
(not in the domain) so it throws anArithmeticException
whenb
is0
.)
Use Option
If there is only one case that can fail to get the expected result, use Option
. The one failure case does not necessarily mean it is an error. A good example might be finding an element. Let's say you're looking for a user using user's ID. The user with the given ID may or may not exist.
e.g.)
final case class User(
id: User.Id,
firstName: User.FirstName,
lastName: User.LastName
)
object User {
final case class Id(id: Long) extends AnyVal
final case class FirstName(firstName: String) extends AnyVal
final case class LastName(lastName: String) extends AnyVal
}
def findUser(userId: User.Id): Option[User] =
// some code to find User
// When there is a user with Id 1L
findUser(User.Id(1L))
// Option[User] = Some(User(Id(1L), FirstName("Kevin"), LastName("Lee")))
// When there is no user with Id 2L
findUser(User.Id(2L))
// Option[User] = None
Use Either
If there are more than one case that can go wrong, use Either
. So it can be either Left
which is a failure or Right
, a successful result.
Left
may contain an error and the it can be defined as an ADT.
e.g.)
final case class Username(username: String) extends AnyVal
sealed trait ValidationError
object ValidationError {
final case class Min(min: Int) extends AnyVal
final case class Max(max: Int) extends AnyVal
final case class InvalidChars(invalidChars: List[Char]) extends AnyVal
final case class ValidChars(validChars: String) extends AnyVal
case object EmptyUsername extends ValidationError
final case class UsernameTooShort(username: String, min: Min) extends ValidationError
final case class UsernameTooLong(username: String, max: Max) extends ValidationError
final case class InvalidUsername(
username: String,
invalidChars: InvalidChars,
validChars: ValidChars
) extends ValidationError
def emptyUsername: ValidationError = EmptyUsername
def usernameTooShort(username: String, min: Min): ValidationError =
UsernameTooShort(username, min)
def usernameTooLong(username: String, max: Max): ValidationError =
UsernameTooLong(username, max)
def invalidUsername(
username: String,
invalidChars: InvalidChars,
validChars: ValidChars
): ValidationError =
InvalidUsername(username, invalidChars, validChars)
// ... other ValidationError data
}
import cats.syntax.either._
def createUsername(username: String): Either[ValidationError, Username] = {
val validChars: List[Char] =
('a' to 'z').toList ++ ('A' to 'Z').toList ++
('0' to '9').toList ++ "-_.".toList
if (Option(username).isEmpty || username.isEmpty)
ValidationError.emptyUsername.asLeft[Username]
else if (username.length < 4)
ValidationError.usernameTooShort(username, ValidationError.Min(4)).asLeft[Username]
else if (username.length > 20)
ValidationError.usernameTooLong(username, ValidationError.Max(20)).asLeft[Username]
else {
val invalidChars = username.filterNot(validChars.contains)
if (invalidChars.nonEmpty)
ValidationError.invalidUsername(
username,
ValidationError.InvalidChars(invalidChars.toList),
ValidationError.ValidChars(validChars.mkString)
).asLeft[Username]
else
Username(username).asRight[ValidationError]
}
}
val validUsername = "kevin"
// validUsername: String = "kevin"
createUsername(validUsername)
// res14: Either[ValidationError, Username] = Right(
// value = Username(username = "kevin")
// )
val invalidUsername1 = ""
// invalidUsername1: String = ""
createUsername(invalidUsername1)
// res15: Either[ValidationError, Username] = Left(value = EmptyUsername)
val invalidUsername2 = "abc"
// invalidUsername2: String = "abc"
createUsername(invalidUsername2)
// res16: Either[ValidationError, Username] = Left(
// value = UsernameTooShort(username = "abc", min = Min(min = 4))
// )
val invalidUsername3 = "abcdefghijklmnopqrstuvwxyz"
// invalidUsername3: String = "abcdefghijklmnopqrstuvwxyz"
createUsername(invalidUsername3)
// res17: Either[ValidationError, Username] = Left(
// value = UsernameTooLong(
// username = "abcdefghijklmnopqrstuvwxyz",
// max = Max(max = 20)
// )
// )
val invalidUsername4 = "abc$def%g.h-i_j*k"
// invalidUsername4: String = "abc$def%g.h-i_j*k"
createUsername(invalidUsername4)
// res18: Either[ValidationError, Username] = Left(
// value = InvalidUsername(
// username = "abc$def%g.h-i_j*k",
// invalidChars = InvalidChars(invalidChars = List('$', '%', '*')),
// validChars = ValidChars(
// validChars = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-_."
// )
// )
// )