EXPEDIA GROUP TECHNOLOGY — ENGINEERING
Functional Programming with Kotlin Arrow
Handle errors cleanly and make temporal dependencies explicit
A lot of Kotlin features can be traced back to Functional Programming languages, such as:
- Heavy use of immutability by default and stream processing functions like
map
andfilter
- Type inference
- Data classes, which enable pattern matching
- Null safety and dealing with the absence of values
However, Kotlin is missing many incredibly useful features, like Either
and Try
, which are ubiquitous in functional programming languages.
Kotlin ended up with competing libraries providing such features, but the authors realized it would be better to team up, so the competing libraries have been merged and the Arrow library is now the “standard” functional library for Kotlin.
Why should we care?
Let’s look at a fictitious Java code example:
class PasswordResource { Response changePassword (
String userId,
String currentPassword,
String desiredPassword
){
changePasswordService
.changePassword(
userId,
currentPassword,
desiredPassword
);
return Response.ok();
}
}class PasswordService { void changePassword(
String userId,
String currentPassword,
String desiredPassword
){
authenticationThingie.authenticate(userId, currentPassword);
passwordDao
.getMostRecentPasswordHashes(userId)
.stream()
.forEach(recentPasswordHash -> {
if (recentPasswordHash.equalsIgnoreCase(hash(desiredPassword))) {
throw new ReusedPasswordException();
}
});
passwordDao.changePassword(userId, desiredPassword);
}
}class AuthenticationThingie { void authenticate(
String userId,
String currentPassword,
String desiredPassword
){
throw new NotAuthenticatedException();
}
}class PasswordDao { void changePassword(
String userId,
String desiredPassword
){
throw new ChangePasswordException();
}
}class GlobalExceptionHandler { handle(Exception e){
if (e instanceof NotAuthenticatedException) {
return Response.unauthorized();
}
if (e instanceof ChangePasswordException) {
return Response.serverError();
}
}
}
A few things to note:
- There are implicit temporal dependencies: Before calling
passwordDao.changePassword
, theuserId
is authenticated and the password check to be sure it isn’t a reused one, but there’s no compile time enforcement of this. - Despite the methods declaring they are
void
(“I don’t return anything”), the call topasswordService.changePassword
does have results — it can be successful or result in three different exceptions thrown by our own code, none of which are listed in the method signatures. In order to find this out, the developer has to read through each and every line of every method potentially called as a result of callingpasswordService.changePassword
. NotAuthenticatedException
andChangePasswordException
are not caught anywhere in the code in context. They are caught in some completely different part of the codebase, maybe becauseNotAuthenticatedError
was considered generic enough to have been wired up as part of work on some other endpoint (which is itself problematic, because you might want to return different responses for the same error in different contexts) and someone saw thatNotAuthenticatedException
was caught in theGlobalExceptionHandler
so they added theChangePasswordException
there too, even though it shouldn’t be there either.ReusedPasswordException
isn’t caught by anything, because the compiler doesn’t force anyone to, and will result in an overlooked 500.
With Kotlin Arrow, this could be refactored to
typealias AuthenticatedUserId = String typealias NonReusedPassword = String sealed class ChangePasswordSuccesses {
class ChangePasswordSuccess() : ChangePasswordSuccesses()
} sealed class ChangePasswordErrors {
class NotAuthenticated() : ChangePasswordErrors()
class ChangePasswordError() : ChangePasswordErrors()
class ReusedPassword() : ChangePasswordErrors()
}class AuthenticationThingie { fun authenticate(
userId: String,
currentPassword: String
) : Either<NotAuthenticated, AuthenticatedUserId> {
//...authenticate
}
}class PasswordDao { fun changePassword(
userId: AuthenticatedUserId,
desiredPassword: NonReusedPassword
) : Either<ChangePasswordError, ChangePasswordSuccess> {
//...change the password
}
}class PasswordService { fun nonReusedPassword(
userId: AuthenticatedUserId,
desiredPassword: String
) : Either<ChangePasswordErrors, Pair<AuthenticatedUser, NonReusedPassword>> {
//...check for reused password
}fun changePassword(
userId: String,
currentPassword: String,
desiredPassword: String
) : Either<ChangePasswordErrors, ChangePasswordSuccess> {
return authenticationThingie
.authenticate(
userId,
currentPassword
) //Either<NotAuthenticated, AuthenticatedUserId>
.flatMap { authenticatedUser ->
nonReusedPassword(
authenticatedUser,
desiredPassword
) //Either<ReusedPassword, NonReusedPassword>
}.flatMap { userAndPassword ->
passwordDao
.changePassword(
userAndPassword.first,
userAndPassword.second
) //Either<ChangePasswordError, Success>
}
}
}}class PasswordResource { fun changePassword(
userId: String,
currentPassword: String,
desiredPassword: String
) : Response {
return passwordService
.changePassword(
userId,
currentPassword,
desiredPassword
) //Either<ChangePasswordErrors, ChangePasswordSuccess>
.fold({ //left (error) case
when (it) {
is ChangePasswordErrors.NotAuthenticated -> { Response.status(401).build() }
is ChangePasswordErrors.ChangePasswordError -> { Response.status(500).build() }
is ChangePasswordErrors.ReusedPassword -> { Response.status(400).build() }
}
}, { //right case
return Response.ok()
})
}
}
Note:
- Implicit temporal dependencies have been made explicit — to call
passwordDao.changePassword
, aNonReusedPassword
is required, and the only way to get one is from thenonReusedPassword
method. - Methods no longer return things they say they don’t. At every layer, each method explicitly says in the method signature what it returns. There’s no need to look around each and every line of every method in every layer. Developers can tell from a glance at the resource method what the endpoint will return for each outcome, in context.
- Errors are clearly enumerated in a single place.
- Errors are guaranteed to be exhaustively mapped because Kotlin enforces that sealed classes are exhaustively mapped at compile time. So a 500 resulting from forgetting to catch a
ReusedPasswordException
is impossible, and if new errors are added without being mapped to HTTP responses, the compiler will let us know.
This is just one out of countless examples of how data types like Either
can be incredibly useful, increase safety by moving more errors to compile time etc etc.
A common criticism of this style is that it’s verbose, there are too many types, and it can be hard to follow if you’re not used to it. The thing is, that’s the price you pay for more accurately modelling the computation at each step, and as we’ve seen, the more imperative alternative is “easy to follow” only because it omits important things that can go wrong at each step, which doesn’t mean they’re not there — they are there, they’re just hidden in different implicit code paths spread across the layers.
I obviously like this style of programming a lot, but this is not just my opinion — hopefully, by now you can see from the above why this style and these types are ubiquitous in functional programming languages. The Expedia Group™️ Partner Central Access team has successfully been using Arrow for about a year or more now, and I’m glad to see it’s slowly being considered for adoption in other teams as well.
I hope I’ve piqued your interest enough to consider using Arrow in your projects.