Functional Programming with Kotlin and Arrow – More on Typeclasses

Continuing the Functional Programming with Kotlin and Arrow Part 2: Categories and Functors tutorial, you’ll now go even further, using a specific and common use case, with a better understanding of data types and typeclasses, from Functor to Monad, passing through Applicatives and Semigroups. By Massimo Carli.

Leave a rating/review
Download materials
Save for later
Share
You are currently viewing page 3 of 4 of this article. Click here to view the first page.

Devising a Better Syntax For Applicatives

In the previous example, you had to deal with gaggles of parentheses and dots. Kotlin and functional programming can help you with that.

Add the following to ResultApplicative.kt in the typeclasses sub-module:

infix fun <E, A, B> Result<E, (A) -> B>.appl(a: Result<E, A>) = a.ap(this)

Here, you basically flip the receiver of the function with its argument. You can now replace the previous code for main() in User.kt with the following:

fun main() {
  val idAp = justResult(1)
  val nameAp = justResult("Max")
  val missingNameAp = Error(IllegalStateException("Missing name!"))
  val emailAp = justResult("max@maxcarli.it")
  val userAp = justResult(userBuilder)
  // 1
  (userAp appl idAp appl nameAp appl emailAp).mapRight { println(it) }
  // 2
  (userAp appl idAp appl missingNameAp appl emailAp).mapLeft { println(it) }
}

Here’s what’s happening in the code:

  1. Create the User and passing the values for its parameters in order.
  2. Using the same code but getting an Error<IllegalStateException> in case any of the parameters are missing.

Run the code. You’ll get the following output for the different use cases:

User(id=1, name=Max, email=max@maxcarli.it)
java.lang.IllegalStateException: Missing name!

Using an Applicative for Validation

The previous code is good, but you can do better. For instance, you can add the following in UserValidation.kt in the main module. Create a file called UserValidation.kt and copy the following code in:

// 1
class ValidationException(msg: String) : Exception(msg)

// 2
fun validateName(name: String): Result<ValidationException, String> =
  if (name.length > 4) Success(name) else Error(ValidationException("Invalid Name"))

// 3
fun validateEmail(email: String): Result<ValidationException, String> =
  if (email.contains("@")) Success(email) else Error(ValidationException("Invalid email"))

With this code you’ve:

  1. Created ValidationException, which encapsulates a possible validation error message.
  2. Defined validateName() to check if the name is longer than four characters.
  3. Created validateEmail() to validate the email and check if it contains the @ symbol.

Both functions return a Error<ValidationException> if the validation fails. You can now use them like in the following code.

Copy the following code into UserValidation.kt, then run the method.:

fun main() {
  // 1
  val idAp = justResult(1)
  val userAp = justResult(userBuilder)
  // 2
  val validatedUser = userAp appl idAp appl validateName("Massimo") appl validateEmail("max@maxcarli.it")
  // 3
  validatedUser.bimap({
    println("Error: $it")
  }, {
    println("Validated user: $it")
  })
}

With this code, you’ve:

  1. Initialized idAp and userAp as before.
  2. Used validateName() and validateEmail() to validate the input parameter.
  3. Printed the result using the bimap() you created earlier.

This code should now create the User, but only if the validation is successful or an incorrect validation displays the error message.

Learning More About Errors With Semigroups

In the previous code, you learned a possible usage for the applicative typeclass using some validator functions. You can still improve how to manage validation error, though.

Create a new file named UserSemigroup.kt in the main module. Then copy and run the following code:

fun main() {
  val idAp = justResult(1)
  val userAp = justResult(userBuilder)
  val validatedUser = userAp appl idAp appl validateName("Max") appl validateEmail("maxcarli.it")
  validatedUser.bimap({
    println("Error: $it")
  }, {
    println("Validated user: $it")
  })
}

You’ll get the following output:

Error: com.raywenderlich.fp.ValidationException: Invalid email

Although both the validators fail, you only get the last error message. You lost the information about the first validation error for the length of the name. In cases like this, you can use semigroup typeclasses. Basically, these define how to combine the information encapsulated in the context of two data types.

To understand how this works, create a new file named ValidationSemigroup.kt in the typeclasses sub-module. Then copy the following code:

// 1
interface Semigroup<T> {
  operator fun plus(rh: T): T
}

// 2
class SgValidationException(val messages: Array<String>) : Semigroup<SgValidationException> {
  // 3
  override operator fun plus(rh: SgValidationException) =
    SgValidationException(this.messages + rh.messages)
}

With this code you’ve:

  1. Created Semigroup<T>, an interface that abstracts objects with the plus() operation.
  2. Defined a new SgValidationException to encapsulate an array of error messages and implement Semigroup<SgValidationException>. This means you can add an instance of SgValidationException to another one.
  3. Overloaded the + operator in a way that combines two instances of SgValidationException to create a new one with error messages that unit both.

You can now replace the implementation of ap() into the ResultApplicative.kt file. Use the following:

// 1
fun <E : Semigroup<E>, T, R> Result<E, T>.ap(fn: Result<E, (T) -> R>): Result<E, R> = when (fn) {
  is Success<(T) -> R> -> mapRight(fn.a)
  is Error<E> -> when (this) {
    is Success<T> -> Error(fn.e)
    // 2
    is Error<E> -> Error(this.e + fn.e)
  }
}

Here you’ve:

  1. Added the constraint to the type E for being a Semigroup<E>.
  2. Combined the errors in case both the objects in play are Error<E>.

In the same file, you have to replace the current implementation for appl() with the following:

infix fun <E : Semigroup<E>, A, B> Result<E, (A) -> B>.appl(a: Result<E, A>) = a.ap(this)

This will add the same constraints to the type E and will break the main() into the User.kt file. You can now replace that file with the following code, which uses Error<SgValidationException> as error type:

fun main() {
  val idAp = justResult(1)
  val nameAp = justResult("Max")
  val missingNameAp = Error(SgValidationException(arrayOf("Missing name!"))) // HERE
  val emailAp = justResult("max@maxcarli.it")
  val userAp = justResult(userBuilder)
  // 1
  (userAp appl idAp appl nameAp appl emailAp).mapRight { println(it) }
  // 2
  (userAp appl idAp appl missingNameAp appl emailAp).mapLeft { println(it) }
}

Next, replace validateName() and validateEmail() in UserValidation.kt with the following code:

fun validateName(name: String): Result<SgValidationException, String> =
  if (name.length > 4) Success(name)
  else Error(SgValidationException(arrayOf("Invalid Name")))

fun validateEmail(email: String): Result<SgValidationException, String> =
  if (email.contains("@")) Success(email)
  else Error(SgValidationException(arrayOf("Invalid email")))

Here, you’ve replaced ValidationException with SgValidationException, which needs an array of error messages. You can now test how it works by replacing main() in UserValidation.kt with the following:

fun main() {
  val idAp = justResult(1)

  val userAp = justResult(userBuilder)

  val validatedUser = userAp appl idAp appl validateName("Max") appl validateEmail("maxcarli.it")
  validatedUser.bimap({
    it.messages.forEach {
      println("Error: $it")
    }
  }, {
    println("Validated user: $it")
  })
}

Because both the validations are not successful, you’ll get an output like this:

Error: Invalid email
Error: Invalid Name

As you can see, it contains both error messages. This is because SgValidationException is a Semigroup.