Monads composition API that just works. For OOP developers. It is well suited to compose also Kotlin nullables.
I know, we have Arrow that is the best functional library around. Anyway if you only want to do simple tasks, like validating your domain classes, Arrow is a bit of an overkill.
Also, Arrow is a real functional library, with a plenty of functional concepts that you need to digest before being productive. For the typical OOP developer, it has a quite steep learning curve.
Here it comes Konad. It has only three classes:
- Result: can be
Result.Ok
orResult.Errors
. - Validation: can be
Validation.Success
orValidation.Fail
. - Maybe: you know this... yet another Optional/Option/Nullable whatever. (But read the Maybe section below, it will get clear why we need it)
These are monads and applicative functors, so they implement the usual map
, flatMap
and ap
methods.
Konad exists with the only purpose to let you easily compose these three classes.
Advanced use-cases examples are described here:
Add the dependency
add in pom.xml
<dependency>
<groupId>io.github.lucapiccinelli</groupId>
<artifactId>konad</artifactId>
<version>1.2.6</version>
</dependency>
add in build.gradle
dependencies {
implementation "io.github.lucapiccinelli:konad:1.2.6"
}
For an exaustive list of usage examples, please refer to test suite CreateNewUserTests.kt and to ResultTests.kt
Let's say you have a User
class, that has an Email
and a PhoneNumber
. Email and PhoneNumber are built so that they can only be constructed using a factory method. It will return a Result.Errors
type if the value passed is not valid.
data class User(val username: String, val email: Email, val phoneNumber: PhoneNumber, val firstname: String)
data class Email private constructor (val value: String) {
companion object{
fun of(emailValue: String): Result<Email> = if (Regex(EMAIL_REGEX).matches(emailValue))
Email(emailValue).ok()
else "$emailValue doesn't match an email format".error()
}
}
data class PhoneNumber private constructor(val value: String){
companion object {
fun of(phoneNumberValue: String): Result<PhoneNumber> = if(Regex(PHONENUMBER_REGEX).matches(phoneNumberValue))
PhoneNumber(phoneNumberValue).ok()
else "$phoneNumberValue should match a valid phone number, but it doesn't".error()
}
}
Email
and PhoneNumber
constructors are private, so that you can be sure that it can't exist a User
with invalid contacts. However, the factory methods give you back a Result<Email>/Result<PhoneNumber>
.
In order to compose them and get a Result<User>
you have to do the following
val userResult: Result<User> = ::User +
"foo.bar" +
Email.of("foo.bar") + // This email is invalid -> returns Result.Errors
PhoneNumber.of("xxx") + // This phone number is invalid -> returns Result.Errors
"Foo"
when(userResult){
is Result.Ok -> userResult.toString()
is Result.Errors -> userResult.toList().joinToString(" - ")
}.run(::println) // This is going to print "foo.bar doesn't match an email format - xxx should match a valid phone number, but it doesn't
// or
userResult
.map{ user -> user.toString() }
.ifError { errors -> errors.description(errorDescriptionsSeparator = " - ") }
.run(::println)
Composition happens thanks to concepts named functors and applicative Functors.
I chose to stay simple and practical, then all the methods that implement composition are called on
(See applicativeBuilders.kt).
However, for those who love the functional naming, you can choose this other style. (See applicativeBuildersPureStyle.kt)
val user: Result<User> = ::User.curry()
.apply("foo.bar")
.map(Email.of("foo.bar"))
.ap(PhoneNumber.of("xxx"))
.pure("Foo")
.result
Maybe
is needed only to wrap Kotlin nullables and bring them to a higher-kinded type (see unaryHigherKindedTypes.kt).
In this way on
, can be used to compose nullables.
Its constructor is private because you should avoid using it in order to express optionality. Kotlin nullability is perfect for that purpose.
If ever you tried to compose nullables in Kotlin, then probably you ended up having something like the following
val foo: Int? = 1
val bar: String? = "2"
val baz: Float? = 3.0f
fun useThem(x: Int, y: String, z: Float): Int = x + y.toInt() + z.toInt()
val result1: Int? = foo
?.let { bar
?.let { baz
?.let { useThem(foo, bar, baz) } } }
// or
val result2: Int? = if(foo != null && bar != null && baz != null)
useThem(foo, bar, baz)
else null
This is not very clean. And it gets even worse if would like to give an error message when a null
happens.
Using Konad, nullables can be composed as follows
val result: Int? = ::useThem + foo + bar + baz
or you can choose to give an explanatory message when something is null
val result: Result<Int> = ::useThem +
foo.ifNull("Foo should not be null") +
bar.ifNull("Bar should not be null") +
baz.ifNull("Baz should not be null")
Validation<A, B>
is like an Either
monad, but with the left case accumulation. It is similar to Result<T>
but instead of fixing the error case as a string description, it lets you
decide how you represent the error. Example:
sealed class ResourceError {
data class BadInput(val description: String) : ResourceError()
object NotFound : ResourceError()
object Forbidden : ResourceError()
}
fun readUser(id: String): Validation<ResourceError, User> =
if (id.isBlank()) ResourceError.BadInput("id should not be blank").fail()
else repository.findById(id)?.success() ?: ResourceError.NotFound.fail()
readUser("xxx")
.map { user: User -> println(user) }
.ifFail { failures: Collection<ResourceError> -> println(failures) }
What if you have a List<Result<T>>
and you want a Result<List<T>>
? Then use flatten
extension method.
val r: Result<Collection<Int>> = listOf(Result.Ok(1), Result.Ok(2)).flatten()
Errors get cumulated as usual
val r: Result<Collection<Int>> = listOf(Result.Errors("error1"), Result.Ok(1), Result.Errors("error2"))
.flatten()
when(r){
is Result.Ok -> r.value.toString()
is Result.Errors -> r.description
}.run(::println) // will print error1 - error2
Obviously it works also on nullables: Collection<T?> -> Collection<T>?
val flattened = setOf("a", null, "c").flatten()
flattened shouldBe null
and on Validation
val v: Validation<String, Collection<Int>> = listOf("error1".fail(), 1.success(), "error2".fail()).flatten()
Sometime you need to add some details on an error, or to transform it. Result
and Validation
monads have convenience method for this case.
Examples:
fun checkNotEmpty(value: String) = if(value.isBlank()) "value should not be blank".error() else value.ok()
data class User private constructor(val firstName: String, val lastname: String){
companion object{
fun of(firstname: String, lastname: String): Result<User> = ::User.curry()
.on(checkNotEmpty(firstname))
.on(checkNotEmpty(lastname))
.result
}
}
in this example, if both firstname
and lastname
are blank, then you will get two errors. Unfortunately both of those errors will have the same description, and you will not be
able to distinct which value should not be empty
. To fix, there is the method Result::errorTitle
fun of(firstname: String, lastname: String): Result<User> = ::User.curry()
.on(checkNotEmpty(firstname).errorTitle("firstname"))
.on(checkNotEmpty(lastname).errorTitle("lastname"))
.result
You can find a more detailed specification here: ResultTests
Similarly, Validation
has the mapFail
method, to apply a tranformation on the error case. Examples here
ValidationTests
In case of accumulated errors, both errorTitle
and mapFail
are applied to the entire list of errors.
Since version 1.2.3, there exist an extension method Result<T>.field
that enables to add an error title in a type-safe manner.
fun of(firstname: String, lastname: String): Result<User> = ::User +
checkNotEmpty(firstname).field(User::firstname) +
checkNotEmpty(lastname).field(User::lastname)
In this example, field
will add the name of the property as an error title, while also checking at compile time if the type
of the property matches the type of the corresponding constructor parameter
If you wish to implement your own monads and let them be composable through the on
Konad applicative builders, then you need to implement the interfaces
that are here: Higher-kinded types
Actually, to let your type be composable, it is enough to implement the ApplicativeFunctorKind
interface.
Kotlin doesn't natively supports Higher-kinded types. To implement them, Konad is inspired on how those are implemented in Arrow.
That is why there is the need of .result
and .nullable
extension properties.