Skip to main content

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.

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

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.

Types.scala
import io.estatico.newtype.macros.{newtype, newsubtype}

object Types {
@newtype case class BoxedNum(n: Int)
@newsubtype case class Num(n: Int)
}
MyApp.scala
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 where 0 is an input value for the right type of the second param b yet it is not a valid value for b (not in the domain) so it throws an ArithmeticException when b is 0.)

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-_."
// )
// )
// )

Reference

ScalaSyd Ep42 - 02. Life Without Stacktraces by Charles O'Farrell