Functional Error Handling in Kotlin using Arrow Kt
Functional error handling is a method of managing errors in programming that aligns with the principles of functional programming. Instead of relying on traditional exception handling mechanisms (like try-catch
blocks), functional error handling uses types and constructs that explicitly represent the possibility of failure in the type system. This approach makes error handling more predictable, composable, and maintainable.
You can skip to the example if you don’t wan’t to read the theoretical part :)
Benefits of Functional Error Handling
Let’s take a look at some of the key benefits of functional error handling:
1. Predictability and Composability
- Explicit Handling: Errors are treated as part of the function’s return type, making them explicit and predictable. For example, returning a
Result
orOption
type clearly indicates the possibility of failure. - Composability: Functional error handling promotes the use of monads (like
Maybe
,Either
, orResult
), which can be composed seamlessly. This allows for chaining multiple operations without needing to handle errors at each step individually.
2. Type Safety
- Compile-Time Checks: Since errors are part of the type system, many errors can be caught at compile time, reducing runtime surprises. The compiler ensures that all potential error cases are handled.
- Pattern Matching: Functional languages often provide pattern matching, which helps in handling different error cases explicitly and cleanly.
3. Immutability and Side-Effect Management
- Controlled Side Effects: Functional programming emphasizes pure functions and immutability, which leads to better side-effect management. Error handling doesn’t interfere with the program’s state, leading to more predictable behavior.
- Referential Transparency: Functions with functional error handling are referentially transparent, meaning their output depends only on their input, making them easier to reason about and test.
4. Readability and Maintainability
- Clear Error Paths: Since errors are handled explicitly, the flow of the program is clearer. Developers can easily trace how errors are propagated and managed.
- Separation of Concerns: Functional error handling often separates error handling logic from the main business logic, enhancing code readability and maintainability.
5. Better Abstractions
- Higher-Order Functions: Functional programming allows the creation of higher-order functions that abstract common error handling patterns, reducing boilerplate code.
- Reusable Components: Common error handling strategies can be encapsulated in reusable components, leading to cleaner and more modular code.
6. Concurrency and Parallelism
- Safer Concurrency: Functional programming’s emphasis on immutability and pure functions makes concurrent and parallel programming safer and easier to reason about, including error handling in concurrent contexts.
7. Expressive and Declarative Code
- Declarative Syntax: Functional error handling often leads to more expressive and declarative code, where the intent is clear and the handling of different scenarios is more straightforward.
- Less Boilerplate: By using constructs like
map
,flatMap
(orbind
), and other combinators, functional error handling reduces the need for repetitive boilerplate code.
Implementing Authentication
We need to implement authentication in our project. For this, we’ll use the authentication service from Firebase. We’ll create a class called AuthService
that will provide all the authentication related functionality.
class AuthService(
private val auth: FirebaseAuth
) {
// ...
}
Defining the Failure Types
Let’s start by defining the failure types. We’ll define a base class called AuthFailure and other failure types will inherit from this base class.
sealed interface AuthFailure
One thing to note here is that we’re not inheriting from the Exception
class. This is because we treat failures as types in functional error handling rather than throwing exceptions. All other failures will inherit from this base class.
Next, we’ll define all possible failures that we want to handle for the signupWithEmailAndPassword
method that allows users to create a new account using email and password. The possible failures that may occur are as follows:
interface SignupWithEmailAndPasswordFailure : AuthFailure {
data object InvalidEmail : SignupWithEmailAndPasswordFailure
data object WeakPassword : SignupWithEmailAndPasswordFailure
data object AccountAlreadyExists : SignupWithEmailAndPasswordFailure
data class ErrorOccurred(
val cause: Throwable
) : SignupWithEmailAndPasswordFailure
}
Here we have used data objects and classes to define our failures. Now, we can use this failure type in the actual function signature.
suspend fun signupWithEmailAndPassword(
email: String,
password: String
): Either<SignupWithEmailAndPasswordFailure, AuthUser>
The Either
type used here is a functional programming construct used to represent a value that can be one of two possible types. It is commonly used to handle computations that may result in a success or a failure.
An Either
type has two cases:
- Left: Typically represents the failure case.
- Right: Typically represents the success case.
This is a generic type that can hold a value of two different types, one in each case.
Let’s implement the singup functionality:
suspend fun signInWithEmailAndPassword(
email: String,
password: String
): Either<LoginWithEmailAndPasswordFailure, AuthUser> = either {
try {
auth
.signInWithEmailAndPassword(email, password)
.user!!.toAuthUser()
} catch (e: FirebaseAuthException) {
val failure = when (e) {
is FirebaseAuthInvalidCredentialsException -> LoginWithEmailAndPasswordFailure.InvalidCredentials
else -> LoginWithEmailAndPasswordFailure.ErrorOccurred(e)
}
raise(failure)
} catch (e: FirebaseException) {
raise(LoginWithEmailAndPasswordFailure.ErrorOccurred(e))
}
}
We are using a handy function either
from the arrow-core library that simplifies creation of Either
type. Inside this builder function, we can call raise(failure)
that returns the failure as Either.Left
. This is the recommended approach by arrow library. You can learn more about it from the offical docs.
We can follow the same approach and create other methods as well.
sealed interface AuthFailure
interface SignupWithEmailAndPasswordFailure : AuthFailure {
data object InvalidEmail : SignupWithEmailAndPasswordFailure
data object WeakPassword : SignupWithEmailAndPasswordFailure
data object AccountAlreadyExists : SignupWithEmailAndPasswordFailure
data class ErrorOccurred(
val cause: Throwable
) : SignupWithEmailAndPasswordFailure
}
interface LoginWithEmailAndPasswordFailure : AuthFailure {
data object InvalidCredentials : LoginWithEmailAndPasswordFailure
data class ErrorOccurred(
val cause: Throwable
) : LoginWithEmailAndPasswordFailure
}
class AuthService(
private val auth: FirebaseAuth
) {
suspend fun signupWithEmailAndPassword(
email: String,
password: String
): Either<SignupWithEmailAndPasswordFailure, AuthUser> = either {
try {
auth
.createUserWithEmailAndPassword(email, password)
.user!!.toAuthUser()
} catch (e: FirebaseAuthException) {
val failure = when (e) {
is FirebaseAuthWeakPasswordException -> SignupWithEmailAndPasswordFailure.WeakPassword
is FirebaseAuthInvalidCredentialsException -> SignupWithEmailAndPasswordFailure.InvalidEmail
is FirebaseAuthUserCollisionException -> SignupWithEmailAndPasswordFailure.AccountAlreadyExists
else -> SignupWithEmailAndPasswordFailure.ErrorOccurred(e)
}
raise(failure)
} catch (e: FirebaseException) {
raise(SignupWithEmailAndPasswordFailure.ErrorOccurred(e))
}
}
suspend fun signInWithEmailAndPassword(
email: String,
password: String
): Either<LoginWithEmailAndPasswordFailure, AuthUser> = either {
try {
auth
.signInWithEmailAndPassword(email, password)
.user!!.toAuthUser()
} catch (e: FirebaseAuthException) {
val failure = when (e) {
is FirebaseAuthInvalidCredentialsException -> LoginWithEmailAndPasswordFailure.InvalidCredentials
else -> LoginWithEmailAndPasswordFailure.ErrorOccurred(e)
}
raise(failure)
} catch (e: FirebaseException) {
raise(LoginWithEmailAndPasswordFailure.ErrorOccurred(e))
}
}
suspend fun logout() { auth.signOut() }
}
Now we can easily utilize these methods anywhere in our application. The Either
provides an extension function called fold
that we can use to process both failure and data easily.
fun signupUser(email: String, password: String) {
scope.launch {
auth.signupWithEmailAndPassword(
email = email,
password = password
).fold(
ifLeft = { failure ->
// Handle failure
},
ifRight = { user ->
// Handle success
}
)
}
}
Resources
- https://arrow-kt.io/learn/typed-errors/working-with-typed-errors
- Using Either with Retrofit Sample
- NewsLayer — Open Source News App with Customized Feed
Find me on LinkedIn.