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.
Sign up/Sign in
With a free Kodeco account you can download source code, track your progress, bookmark, personalise your learner profile and more!
Create accountAlready a member of Kodeco? Sign in
Sign up/Sign in
With a free Kodeco account you can download source code, track your progress, bookmark, personalise your learner profile and more!
Create accountAlready a member of Kodeco? Sign in
Sign up/Sign in
With a free Kodeco account you can download source code, track your progress, bookmark, personalise your learner profile and more!
Create accountAlready a member of Kodeco? Sign in
Contents
Functional Programming with Kotlin and Arrow – More on Typeclasses
30 mins
- Getting Started
- Finding a Functional Fetcher
- Introducing the Result<E,T> Data Type
- Implementing Result<E,T> as Functor
- Practicing with the Bifunctor
- Defining the Applicative Functor
- Setting the Applicative to Work
- Devising a Better Syntax For Applicatives
- Using an Applicative for Validation
- Learning More About Errors With Semigroups
- Presenting Monads
- Creating the Result<E, T> Monad
- Where to Go From Here?
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:
- Create the
User
and passing the values for its parameters in order. - 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:
- Created
ValidationException
, which encapsulates a possible validation error message. - Defined
validateName()
to check if the name is longer than four characters. - 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:
- Initialized
idAp
anduserAp
as before. - Used
validateName()
andvalidateEmail()
to validate the input parameter. - 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:
- Created
Semigroup<T>
, an interface that abstracts objects with theplus()
operation. - Defined a new
SgValidationException
to encapsulate an array of error messages and implementSemigroup<SgValidationException>
. This means you can add an instance ofSgValidationException
to another one. - 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:
- Added the constraint to the type
E
for being aSemigroup<E>
. - 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
.