Skip to content

Instantly share code, notes, and snippets.

@androidfred
Last active December 7, 2023 17:42
Show Gist options
  • Star 12 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save androidfred/3f9245d050a9f8544ef8a9e056cc9a70 to your computer and use it in GitHub Desktop.
Save androidfred/3f9245d050a9f8544ef8a9e056cc9a70 to your computer and use it in GitHub Desktop.
Kotlin Arrow

Kotlin Arrow

A lot of Kotlin features can be traced back to functional programming languages, eg

  • Heavy use of immutability-by-default and map, filter etc functions
  • Type inference
  • Data classes (which enable pattern matching)
  • Null safety and dealing with the absence of values

However, Kotlin is missing many incredibly useful data types that are ubiquitous in functional programming languages, eg Either, Try etc.

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.

https://arrow-kt.io

Why should we care?

Let's look at an example: (I made it up but the style is not dissimilar to a lot of the code in our repos)

class PasswordResource {

    Response changePassword (
            String userId, 
            String currentPassword, 
            String desiredPassword
    ){        
        changePasswordService
            .changePassword(
                userId, 
                oldPassword, 
                newPassword
            );
        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, eg before calling passwordDao.changePassword that the userId is authenticated and the password 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 to passwordService.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 out, the developer has to read through each and every line of every method potentially called as a result of calling passwordService.changePassword.

  • NotAuthenticatedException and ChangePasswordException are not caught anywhere in the code in context. They are caught in some completely different part of the codebase, maybe because NotAuthenticatedError was considered generic enough to have been wired up as part of work on some other endpoint (which is itself problematic as well, because you might want to return different responses for the same error in different contexts) and someone saw that NotAuthenticatedException was caught in the GlobalExceptionHandler so added the ChangePasswordError 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, 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 { nonreusedpassword ->
                        passwordDao
                            .changePassword(
                                authenticatedUser, 
                                nonreusedpassword
                            ) //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()
                })
               
    }
    
}

A few things to note

  • Implicit temporal dependencies have been made explicit, and enforced at compile time, because it's no longer possible to call passwordDao.changePassword with a String- a NonReusedPassword is required, and the only way to get one is from the nonReusedPassword 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. Eg, developers can tell from a glance at the resource method 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 scream)

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 wordier, 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.

So, I personally obviously like this style of programming a lot, but this is not just my opinion- there's a reason why this style and these types are ubiquitous in functional programming languages.

I hope I've piqued your interest enough to consider using Arrow in your projects.

https://arrow-kt.io

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment