michaelbull / kotlin-result

A multiplatform Result monad for modelling success or failure operations.
ISC License
1.05k stars 63 forks source link
browser class either fp functional functional-programming ios js jvm kotlin kotlin-multiplatform monad result type

kotlin-result

Maven Central CI License

badge badge badge badge badge badge badge badge badge badge badge badge badge badge

A multiplatform Result monad for modelling success or failure operations.

Installation

repositories {
    mavenCentral()
}

dependencies {
    implementation("com.michael-bull.kotlin-result:kotlin-result:2.0.0")
}

Introduction

In functional programming, the result Result type is a monadic type holding a returned value or an error.

To indicate an operation that succeeded, return an Ok(value) with the successful value. If it failed, return an Err(error) with the error that caused the failure.

This helps to define a clear happy/unhappy path of execution that is commonly referred to as Railway Oriented Programming, whereby the happy and unhappy paths are represented as separate railways.

Overhead

The Result type is modelled as an inline value class. This achieves zero object allocations on the happy path.

A full breakdown, with example output Java code, is available in the Overhead design doc.

Multiplatform Support

kotlin-result targets all three tiers outlined by the Kotlin/Native target support

Read More

Below is a collection of videos & articles authored on the subject of this library. Feel free to open a pull request on GitHub if you would like to include yours.

Mappings are available on the wiki to assist those with experience using the Result type in other languages:

Getting Started

Below is a simple example of how you may use the Result type to model a function that may fail.

fun checkPrivileges(user: User, command: Command): Result<Command, CommandError> {
    return if (user.rank >= command.mininimumRank) {
        Ok(command)
    } else {
        Err(CommandError.InsufficientRank(command.name))
    }
}

When interacting with code outside your control that may throw exceptions, wrap the call with runCatching to capture its execution as a Result<T, Throwable>:

val result: Result<Customer, Throwable> = runCatching {
    customerDb.findById(id = 50) // could throw SQLException or similar
}

Nullable types, such as the find method in the example below, can be converted to a Result using the toResultOr extension function.

val result: Result<Customer, String> = customers
    .find { it.id == id } // returns Customer?
    .toResultOr { "No customer found" }

Transforming Results

Both success and failure results can be transformed within a stage of the railway track. The example below demonstrates how to transform an internal program error UnlockError into the exposed client error IncorrectPassword.

val result: Result<Treasure, UnlockResponse> =
    unlockVault("my-password") // returns Result<Treasure, UnlockError>
    .mapError { IncorrectPassword } // transform UnlockError into IncorrectPassword

Chaining

Results can be chained to produce a "happy path" of execution. For example, the happy path for a user entering commands into an administrative console would consist of: the command being tokenized, the command being registered, the user having sufficient privileges, and the command executing the associated action. The example below uses the checkPrivileges function we defined earlier.

tokenize(command.toLowerCase())
    .andThen(::findCommand)
    .andThen { cmd -> checkPrivileges(loggedInUser, cmd) }
    .andThen { execute(user = loggedInUser, command = cmd, timestamp = LocalDateTime.now()) }
    .mapBoth(
        { output -> printToConsole("returned: $output") },
        { error  -> printToConsole("failed to execute, reason: ${error.reason}") }
    )

Binding (Monad Comprehension)

The binding function allows multiple calls that each return a Result to be chained imperatively. When inside a binding block, the bind() function is accessible on any Result. Each call to bind will attempt to unwrap the Result and store its value, returning early if any Result is an error.

In the example below, should functionX() return an error, then execution will skip both functionY() and functionZ(), instead storing the error from functionX in the variable named sum.

fun functionX(): Result<Int, SumError> { ... }
fun functionY(): Result<Int, SumError> { ... }
fun functionZ(): Result<Int, SumError> { ... }

val sum: Result<Int, SumError> = binding {
    val x = functionX().bind()
    val y = functionY().bind()
    val z = functionZ().bind()
    x + y + z
}

println("The sum is $sum") // prints "The sum is Ok(100)"

The binding function primarily draws inspiration from Bow's binding function, however below is a list of other resources on the topic of monad comprehensions.

Coroutine Binding Support

Use of suspending functions within a coroutineBinding block requires an additional dependency:

dependencies {
    implementation("com.michael-bull.kotlin-result:kotlin-result:2.0.0")
    implementation("com.michael-bull.kotlin-result:kotlin-result-coroutines:2.0.0")
}

The coroutineBinding function runs inside a coroutineScope, facilitating concurrent decomposition of work.

When any call to bind() inside the block fails, the scope fails, cancelling all other children.

The example below demonstrates a computationally expensive function that takes five milliseconds to compute being eagerly cancelled as soon as a smaller function fails in just one millisecond:

suspend fun failsIn5ms(): Result<Int, DomainErrorA> { ... }
suspend fun failsIn1ms(): Result<Int, DomainErrorB> { ... }

runBlocking {
    val result: Result<Int, BindingError> = coroutineBinding { // this creates a new CoroutineScope
        val x = async { failsIn5ms().bind() }
        val y = async { failsIn1ms().bind() }
        x.await() + y.await()
    }

    // result will be Err(DomainErrorB)
}

Inspiration

Inspiration for this library has been drawn from other languages in which the Result monad is present, including:

Improvements on existing solutions such the stdlib include:

Example

The example module contains an implementation of Scott's example application that demonstrates the usage of Result in a real world scenario.

It hosts a ktor server on port 9000 with a /customers endpoint. The endpoint responds to both GET and POST requests with a provided id, e.g. /customers/100. Upserting a customer id of 42 is hardcoded to throw an SQLException to demonstrate how the Result type can map internal program errors to more appropriate user-facing errors.

Contributing

Bug reports and pull requests are welcome on GitHub.

License

This project is available under the terms of the ISC license. See the LICENSE file for the copyright information and licensing terms.