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?
Implementing Result<E,T> as Functor
You can think of a typeclass as a definition that abstracts a common behavior between different data types. A functor is probably one of the most common. It provides a function map()
that, for Result<E,T>
, can have two different flavors if applied to the success value of the error.
To define this, create a ResultFunctor.kt into the typeclasses sub-module. Then copy the following code:
fun <E1, E2, T> Result<E1, T>.mapLeft(fn: (E1) -> E2): Result<E2, T> = when (this) {
is Success<T> -> this
is Error<E1> -> Error(fn(this.e))
}
The function you pass as a parameter has type (E1) -> E2
, and it has effect only in case Result<E,T>
is an Error<E>
. In case it’s a Success<T>
, mapLeft()
returns the same object.
You can follow the same approach for Success<T>
. Copy this code in the same file:
fun <E, T, R> Result<E, T>.mapRight(fn: (T) -> R): Result<E, R> = when (this) {
is Success<T> -> Success(fn(this.a))
is Error<E> -> this
}
In this case, the function you pass as a parameter has type (T) -> R
. mapRight()
applies the function, but only if the current Result<E,T>
is a Success<T>
. If it’s an Error<E>
, it returns the same object.
Because there are two different versions of the map()
function, this typeclass is also called a bifunctor. You can provide another version of this by adding the following code to the same file:
fun <E1, E2, T, R> Result<E1, T>.bimap(fe: (E1) -> E2, fs: (T) -> R): Result<E2, R> = when (this) {
is Success<T> -> Success(fs(this.a))
is Error<E1> -> Error(fe(this.e))
}
As you can see, the implementation of bimap()
has both the functions for the success and error cases as parameters.
Practicing with the Bifunctor
You can now go back to FunctionalFetcher.kt and replace the existing main()
with the following:
fun main() {
val ok_url = URL("https://jsonplaceholder.typicode.com/todos")
val error_url = URL("https://error_url.txt")
// 1
val printErrorFun = { ex: FetcherException -> println("Error with message ${ex.message}") }
// 2
val printString = { str: String -> print(str) }
// 3
FunctionalFetcher.fetch(error_url)
.bimap(printErrorFun, printString)
}
This is similar to before. But this time you:
- Defined
printErrorFun()
, which prints out the message of aFetcherException
. - Created
printString()
, which prints out the content fetched from the network as a simpleString
. - Used the two functions as a parameter for the
bimap()
you defined earlier.
Now, you can easily run the code and see the output for success or failure.
In case of success, you can print the text you receive from the network. It will look something like this:
[ { "userId": 1, "id": 1, "title": "delectus aut autem", "completed": false }, { "userId": 1, "id": 2, "title": "quis ut nam facilis et officia qui",
- - -
{ false }]
In case of error, however, you’ll see this error message:
Error with message error_url.txt
Defining the Applicative Functor
The applicative is another important typeclass. It adds two new functions to the functor.
Start by creating a new ResultApplicative.kt in the typeclass submodule. Then copy the following code:
fun <T> justResult(value: T) = Success(value)
This code defines justResult()
. This is similar to the type constructor you saw in a previous tutorial. It’s a function that accepts a value of type T
and creates an instance of a specific data type — Result<E,T>
in this case.
You can typically find this function with the name just()
or pure()
. You use the justResult()
to avoid the usage of companion objects or extension functions and leverage the type inference Kotlin provides.
After this, you can copy the following code in the same file:
// 1
fun <E, T, R> Result<E, T>.ap(fn: Result<E, (T) -> R>): Result<E, R> = when (fn) {
// 2
is Success<(T) -> R> -> mapRight(fn.a)
is Error<E> -> when (this) {
// 3
is Success<T> -> Error(fn.e)
// 4
is Error<E> -> Error(this.e)
}
}
In this code, you define the ap()
function. It:
- Is similar to a functor. However, an important difference is the type of function parameter,
Result<E, (T) -> R>
. The function you want to apply is encapsulated into the same data type you’re creating as an applicative. - Applies the function you’re passing as a parameter of type
Result<E, (T) -> R>
, in case it’s aSuccess<(T)->R>
. Here, you invokemapRight()
, which already takes care of the case when the current object is anError<E>
. - Returns
Error<E>
to encapsulate the error of the parameter in case it’s anError<E>
. - Returns
Error<E>
to encapsulate the error into the current object in case it’s anError<E>
.
An example will help you to better understand how this works.
Setting the Applicative to Work
Let’s create an class called User
. You can define it by copying the following definition into a new User.kt. Create the following in the main src folder:
data class User(val id: Int, val name: String, val email: String)
This data class has three mandatory properties you must provide to have a valid user. To do so, add a builder function to User.kit:
val userBuilder = { id: Int -> { name: String -> { email: String -> User(id, name, email) } } }
The type of this function is:
typealias UserBuilder = (Int) -> (String) -> (String) -> User
User
constructor. You’ll learn about currying in a future tutorial.You can test userBuilder()
by copying and running the following code into the same file:
fun main() {
// 1
val idAp = justResult(1)
val nameAp = justResult("Max")
val emailAp = justResult("max@maxcarli.it")
// 2
val userAp = justResult(userBuilder)
// 3
emailAp.ap(nameAp.ap(idAp.ap(userAp))).mapRight {
println(it)
}
}
Here, you have:
- Encapsulated the value you want to use as parameters into a
Success
of the related type using thejustResult()
function. - Done the same for the
userBuilder()
function. - Invoked
ap()
on theSuccess
. For theid
to get anotherSuccess
, you pass one for thename
. Finally, you do the same for the one about theemail
.
The result is a Success<User>
. You know this is a functor you can call mapRight()
on. Because everything is in place, you’ll get the following output:
User(id=1, name=Max, email=max@maxcarli.it)
As a counter-example, you can change the code in main() with the following where the name is missing:
fun main() {
val idAp = justResult(1)
// 1
val missingNameAp = Error(IllegalStateException("Missing Name!"))
val emailAp = justResult("max@maxcarli.it")
val userAp = justResult(userBuilder)
// 2
emailAp.ap(missingNameAp.ap(idAp.ap(userAp))).mapLeft {
println(it)
}
}
With this code, you’ve:
- Created an
Error<IllegalStateException>
as value for the name. - Used
mapLeft()
to display the error message.
Build and run. You’ll see the following output:
java.lang.IllegalStateException: Missing name!
This means the User
has not been created because the mandatory name is missing.
At this point, you probably noticed that the syntax is not easy to write and wondering if a different approach may be better. You’re absolutely correct! And functional programming can help you devise better syntax.