article banner (priority)

A birds-eye view of Arrow: Error Handling

This is a chapter from the book Functional Kotlin. You can find it on LeanPub or Amazon.

This part was written by Simon Vergauwen, with support from Alejandro Serrano Mena and Raúl Raja Martínez.

When writing code in a functional style, we typically want our function signatures to be as accurate as possible. We don't wish to have errors represented as exceptions; instead, we reflect these errors as part of the return type of a function. Composing functions that return types with errors is more complex.

One of the most common exceptions in Java is NullPointerException, and Kotlin has an elegant solution: nullable types! Kotlin allows us to model the absence of a value through nullable types.

For example, Java offers Integer.parseInt, which can unexpectedly throw NumberFormatException. Still, Kotlin has String.toIntOrNull, which returns Int? as a result type and produces null when a String can't coerce to an Int.

Kotlin doesn't have checked exceptions, so there is no way for a function to signal to the caller that it needs to be wrapped in try-catch. When using nullable types, we can force the user to handle possible null values or failures.

Working with nullable types

Let's take a simple example that reads a value from the environment and results in an optional String value. In the function below, the exception from System.getenv is swallowed and flattened into null.

/** read value from environment, * or null if failed or not present */ fun envOrNull(name: String): String? = runCatching { System.getenv(name) }.getOrNull()

Now we can use this function to read values from our environment and build a simple example of combining nullable functions to load a data class Config(val port: Int). Within Java, the most common way to deal with null is to use if(x != null), so let's explore that first.

fun configOrNull(): Config? { val envOrNull = envOrNull("port") return if (envOrNull != null) { val portOrNull = envOrNull.toIntOrNull() if (portOrNull != null) Config(portOrNull) else null } else null }

The simple example is already considerably complex and contains some repetition. Luckily, the Kotlin compiler has smart-casted the values to non-null inside the branch of each if statement, thus ensuring you can safely access them as non-nullable values.

Kotlin offers much nicer ways of working with nullable types, such as ?. and scoping functions like let.

The same code above can be expressed as:

fun config2(): MyConfig? = envOrNull("port")?.toIntOrNull()?.let(::Config)

The above snippet is more Kotlin idiomatic and easier to read. Sadly, this syntax only works for nullable types; other types such as Result or Either cannot benefit from the special ? syntax.

There are two improvements we could make to the code above:

  • Unify APIs to work with errors and nullable types.
  • Swallow all exceptions from System.getenv into null.

To solve the first issue, we can leverage Arrow's DLSs. A DSL is a Domain Specific Language or an API that is specific to working with a particular domain. Arrow offers DSLs based on continuations that offer a unified API for working with all error types.

First, rewrite our above example using the nullable Arrow DSL. The nullable.eager offers us a DSL with bind, which allows us to unwrap Int? to Int.

import arrow.core.continuations.nullable fun config3(): Config? = nullable.eager { val env = envOrNull("port").bind() val port = env.toIntOrNull().bind() Config(port) }

In Arrow 1.x.x there is nullable.eager { } for non-suspend code, and nullable { } for suspend code. In Arrow 2.x.x this will become simply nullable { } for both suspend & non-suspend code.

If we encounter a null value when unwrapping Int? to Int using bind, then the nullable.eager { } DSL will immediately return null without running the rest of the code in the lambda. Using .bind is an easier alternative to applying the Elvis operator on each check and short-circuiting the lambda with an early return:

fun add(a: String, b: String): Int? { val x = a.toIntOrNull() ?: return null val y = b.toIntOrNull() ?: return null return x + y }

To prevent swallowing any exceptions from System.getenv, we can use runCatching and Result from the Kotlin Standard Library.

Working with Result

Result<A> is a special type in Kotlin that we can use to model the result of an operation that may succeed or may result in an exception. To more accurately model our previous operation envOrNull of reading a value from the environment, we use Result to model the failure of System.getenv. Additionally, the environment variable might not be present, so the function should return Result<String?> to also model the potential absence of the environment variable.

Our previous envOrNull can leverage Result as the return type:

fun envOrNull(name: String): Result<String?> = runCatching { System.getenv(name) }

The envOrNull function defined above now correctly models the failure of System.getenv and the potential absence of our environment variable. Now, we need to deal with nullable types inside the context of Result. Luckily, the Arrow DSL offers a DSL for Result that allows us to work with Result in the same way as we did for the nullable types above.

To ensure that our environment variable is present, the Arrow DSL offers ensureNotNull, which checks if the passed value envOrNull is not null and smart-casts it. If ensureNotNull encounters a null value, it returns a Result.failure with the passed exception. In this case, we return Result.failure(IllegalArgumentException("Required port value was null.")) when encountering null.

Finally, we must transform our String into an Int. The most convenient way of doing this inside the Result context is using toInt, which throws a NumberFormatException if the passed value is not a valid Int. When using toInt, we can use runCatching to safely turn it into Result<Int>.

import arrow.core.continuations.result fun config4(): Result<Config> = result.eager { val envOrNull = envOrNull("port").bind() ensureNotNull(envOrNull) { IllegalStateException("Required port value was null") } val port = runCatching { envOrNull.toInt() }.bind() Config(port) }

The example above used Kotlin's Result type to model the different failures to load the configuration:

  • Any exceptions thrown from System.getenv using SecurityException or Throwable
  • The absence of the environment variable using IllegalStateException
  • The failure of toInt using NumberFormatException

If the API you are interfacing with throws exceptions, Result might be the best way to model your use case. If you are designing a library or application, you may want to control your error types, and these types do not need to be part of the Throwable or exception hierarchies.

It doesn't make sense to use Result for every error type you want to model. With Either, we can model the different failures to load the configuration in a more expressive and better-typed way without depending on exceptions or Result.

Working with Either

Before we dive into solving our problem with Either, let's first take a quick look at the Either type itself. Either<E, A> models the result of a computation that might fail with an error of type E or success of type A. It's a sealed class, and the Left and Right subtypes accordingly represent the Error and Success cases.

sealed class Either<out E, out A> { data class Left<E>(val value: E) : Either<E, Nothing>() data class Right<A>(val value: A) : Either<Nothing, A>() }

When modeling our errors with Either, we can use any type to represent failures arising from loading our Config.

In our Result example, we used the following exceptions to model our errors:

  • SecurityException/Throwable when accessing the environment variable
  • IllegalStateException when the environment variable is not present
  • NumberFormatException when the environment variable is present but is not a valid Int

In this new example based on Either, we can instead model our errors with a sealed type ConfigError.

sealed interface ConfigError data class SystemError(val underlying: Throwable) object PortNotAvailable : ConfigError data class InvalidPort(val port: String) : ConfigError

ConfigError is a sealed interface that represents all the different kinds of errors that can occur when loading. During the loading of our configuration, an unexpected system error could occur, such as java.lang.SecurityException. The SystemError type represents this. When the environment variable is absent, we should return the PortNotAvailable type; when the environment variable is present but is not a valid Int, we should return an InvalidPort type.

This new error encoding based on a sealed hierarchy changes our previous example to:

import arrow.core.continuations.either fun config5(): Either<ConfigError, Config> = either.eager { val envOrNull = Either.catch { System.getenv("port") } .mapLeft(::SecurityError) .bind() ensureNotNull(envOrNull) { PortNotAvailable } val port = ensureNotNull(envOrNull.toIntOrNull()) { InvalidPort(env) } Config(port) }

The above example uses Either.catch to catch any exception thrown by System.getenv; it then _map_s them to a SecurityError using mapLeft before calling bind. If we had not mapped our error from Either<Throwable, String?> to Either<SecurityError, String?>, we would not have been able to call bind because our Either<ConfigError, Config> context can only handle errors of type ConfigError. Finally, we use ensureNotNull again to check if the environment variable is present. We also rely on ensureNotNull for the result of the toIntOrNull call.

Our original sample has improved so as to not swallow any exceptions and return all errors in a typed manner.

A final improvement we can still make to the function that loads our configuration is to ensure that the Port is valid. So, we check if the value lies between 0 and 65535; if not, we return our existing error type InvalidPort.

import arrow.core.continuations.either private val VALID_PORT = 0..65536 fun config5(): Either<ConfigError, Config> = either.eager { val envOrNull = Either.catch { System.getenv("port") } .mapLeft(::SecurityError) .bind() val env = ensureNotNull(envOrNull) { PortNotAvailable } val port = ensureNotNull(env.toIntOrNull()) { InvalidPort(env) } ensure(port in VALID_PORT) { InvalidPort(env) } Config(port) }

In the examples above, we've learned that we can have all flavors of error handling with nullable types, Result or Either. We use nullable types when a value can be absent, or we don't have any useful error information; we use Result when the operations may fail with an exception; and we use Either when we want to control custom error types that are not exceptions.