One of the things I love about Scala is how you can write things in so many different ways. Part of the fun is finding the best-looking way to accomplish a task. In this post, I take a big, ugly method with several logical branches and levels of nested conditions, and try to make it as pretty as I can. My quest brings me through call-by-name blocks all the way into some custom functional data structures and ‘for comprehensions’, leading to what I think is a pretty successful ‘beautification’.
The Scenario
Let’s say you’re writing a RESTful web server for… something. In each request you might do things like check the permissions, extract parameters from the request, interpret JSON values from the parameters, or look up values from a database. With each of these operations, there’s a possible failure point; if the permission check fails, you’d want to respond with a 403 Forbidden
response. If parameters are missing, you might want to respond with a 400 Bad Request
. Handling all of these cases and their possible failures is not pretty… so let’s give it a code-makeover!
Here’s how all of that might look at first:
def respondWithChecks(req: Request): Response = {
// 1st logical branch: permission check
if(checkForPermissions(req, someKindOfPermission)){
// 2nd logical branch: parsing the request body as JSON
extractJsonBody(req) match {
case None => Response(400, "Request needs a JSON body")
case Some(json) =>
// 3rd logical branch: extracting params from the JSON
extractJsonField[Int]("userId", json) match {
case None => Response(400, "Request body needs a 'userId' field")
case Some(userId) =>
// 4th logical branch: database lookup
lookupUserFromDB(userId) match {
case None => Response(404, "User not found")
case Some(user) => Response(200, user.toJson)
}
}
}
} else {
Response(403, "Insufficient Permissions")
}
}
Assume these methods are already implemented somewhere.
def checkForPermissions(req: Request, perms: Permissions): Boolean
def extractJsonBody(req: Request): Option[JSON]
def extractJsonField[T](fieldName: String, json: JSON): Option[T]
def lookupUserFromDB(userId: Int): Option[User]
Call-by-Name Blocks
You don’t even have to read the respondWithChecks
method above to see that it gets pretty heavily indented (by 7 tabs at the deepest point)! It also feels pretty Java-ish; the only Scala-ish thing in the method is the use of Options and match blocks.
I can eliminate a few levels of indentation by creating some ‘call-by-name’ style versions of the convenience functions. Here’s the general pattern:
def exampleRequestHandler(someParams: Params)(
onSuccess: SomeValue => Response): Response = {
if(someParams.areValid) {
val someValue = calculateSomeValue(someParams)
onSuccess(someValue)
} else {
someFailureResponse
}
}
In the example above, the function takes some arbitrary input parameters and performs some logical check. If the check passes, it sends a value to the onSuccess
function in order to return a Response. If the check fails, it returns some default failure response. Here’s the lookupUserFromDB
method, wrapped to use this new style:
def withDBUser(userId: Int)(onSuccess: User => Response): Response = {
lookupUserFromDB(userId) match {
case Some(user) => onSuccess(user)
case None => Response(404, "User not found")
}
}
First Attempt
Assuming the other three convenience methods get refactored in a similar way, the original request handler method can be rewritten as follows:
def respondWithChecks(req: Request): Response = {
withPermissions(req, someKindOfPermission){ () =>
withJsonBody(req){ json =>
withJsonField[Int]("userId", json){ userId =>
withDBUser(userId){ user =>
Response(200, user.toJson)
}
}
}
}
}
This seems a little bit better. The deepest level of indentation is now 5 tabs (an improvement of two). Also, the error-condition handling has been extracted into our convenience methods (e.g. withDBUser
giving the 404 response). That makes it easier to reuse the same blocks and not have to repeat that logic for each request handler. Yes, it’s an improvement, but we can do better!
Sequence Comprehensions
I spent some time thinking about this one, and arrived at the conclusion that the most effective way to de-indent this code would be to take advantage of Scala’s “sequence comprehensions” syntax sugar. (Also known as “for comprehensions”. I tend to prefer that way despite the fact that it’s more awkward to write/read/say/hear.)
Scala’s has these comprehensions baked into most of its standard library. Lists
, Options
, Trys
, Futures
, and pretty much every collection have the map
, flatMap
, withFilter
, and foreach
methods defined on them. These four methods are what Scala’s compiler desugars comprehensions into:
for {
i <- listOfInts
j <- anotherList
} yield i * j
// is equivalent to
listOfInts.flatMap { i =>
anotherList.map { j =>
i * j
}
}
If sequence comprehensions are new to you, I encourage you to do a bit of reading on them before continuing down this post. I’m about to dive into the deep end of this concept.
The End Goal
Now here’s the leap I’m making: listOfInts.flatMap { i => ... }
looks a lot like withJsonBody(req){ json => ... }
in its format; maybe I can refactor withJsonBody
to return something that has a flatMap
and map
method so that I can use it in a sequence comprehension, like this:
for {
_ <- withPermissions(req, someKindOfPermission)
json <- withJsonBody(req)
userId <- withJsonField[Int]("userId", json)
user <- withDBUser(userId)
} yield Response(200, user.toJson)
I don’t think you could possibly have less indentation without completely inlining the code. It’s extremely concise; there’s almost no syntactical overhead to this style. It feels pretty readable to me; most of the important bits are indented on the same level, so it reads more like regular text. The values being returned by each step are clearly listed on the left (json
, userId
, and user
). There aren’t a lot of extra brackets and parentheses. Seems nice to me, now let’s make it happen!
The Calculation Data Structure
To make the comprehensions-based approach work, we need to design a data structure that fits its needs:
- Each step can either short-circuit to a return value immediately, or succeed by passing along the input to the next step
- Must have a
flatMap
andmap
method in order to fit a for comprehension.
For our purposes, the end result type should always be the same. All of our convenience methods eventually return a Response
. The value they pass along can vary. Generically, let’s refer to the result type as Result
, and the passed-along input type as B
.
sealed trait Calculation[+Result, +B]
case class Done[Result](value: Result) extends Calculation[Result, Nothing]
case class Next[B](input: B) extends Calculation[Nothing, B]
The Calculation
ADT is a lot like Scala’s Either
; the calculation can either be Done
, containing a result value, or be ready to pass along the next input
to another calculation.
The +
in Calculation[+Result, +B]
marks Result
and B
as covariant types. Note that Done extends Calculation[Result, Nothing]
. Nothing
is a magical class that is a subtype of everything. When combined with being covariant, it means that a Next[B]
could count as a Calculation[String, B]
, or a Calculation[Response, B]
, or a Calculation[PrettyMuchAnything, B]
. This is the same concept as Nil
for Scala’s Lists, or None
for Options.
With the Calculation
data structure in mind, let’s refactor the convenience methods a second time:
def withDBUser(userId: Int): Calculation[Response, User] = {
lookupUserFromDB(userId) match {
case Some(user) => Next(user)
case None => Done(Response(404, "User not found"))
}
}
This version of the function feels pretty similar to the previous one. The arguments to each function get simpler since there is no second argument list, and no onSuccess
function in the signature. Rather than calling onSuccess
, it simply returns a Next
containing the user. If it fails, it returns a Done
with the error response, since the calculation is done at that point. The rest of the convenience methods go pretty much the same way:
def withPermissions(req: Request, perms: Permissions): Calculation[Response, Unit]
def withJsonBody(req: Request): Calculation[Response, JSON]
def withJsonField[T](fieldName: String, json: JSON): Calculation[Response, T]
def withDBUser(userId: Int): Calculation[Response, User]
Note that the Result
type for each of the four convenience methods is Response
, because the end goal of the calculation is a Response
. The B
type is up to the individual methods.
Calculation Mapping
The final piece of the puzzle is to implement a flatMap
and map
method for the Calculation
class. It turns out to be easier than you might think. The hardest part is figuring out how to deal with the consequences of making Result
and B
be covariant.
For flatMap
, the functionality can be read as “If the calculation is done, it stays done. If it’s not done, I should pass my input to the next part of the calculation.”
def flatMap[R2 >: Result, B2](f: B => Calculation[R2, B2]):
Calculation[R2, B2] = this match {
case Done(result) => Done(result)
case Next(input) => Next(f(input))
}
The “next part of the calculation” is represented as the function f
, which takes an input of type B
and returns the next state of the calculation. R2
is there to make the compiler happy with covariance. Basically it allows for f
to return a Calculation with a less-specific Result
type.
The map
method is a little bit tricky. Since it will generally be called from inside a flatMap
function, it needs to return a Calculation[Result, B]
instead of a Result
, even though what we want is the Result
. For now, we can craft the type signature for map
so that it forces the B
type to be equal to the Result
type. Later on we can define a method to help get the Result
value out.
def map[R2 >: Result](f: B => R2): Calculation[R2, R2] = this match {
case Done(result) => Done(result)
case Next(input) => Done(f(input))
}
Again, R2
is there because the Result
type is covariant. You can think of R2
as equivalent to Result
if it helps. Now, to get the actual result value out…
def result[R2 >: Result](implicit eq: B <:< R2): R2 = this match {
case Done(r) => r
case Next(in) => eq(in)
}
Here, I used a cool trick where an implicit “subtype equality” checker method ensures at compile time that B is either equal to, or a subtype of R2
, which itself is either Result
, or a supertype of Result
. (confused yet?) What this boils down to is that if you try calling result
on a Calculation[Response, Unit]
, you’ll get a compile error, but if you try calling result
on a Calculation[Response, Response]
, you’ll get a Response
. Check out the scaladoc for <:<
The Final Result
Now that all of the pieces have been made, we can finally put them together into the new-and-improved respondWithChecks
method!
def respondWithChecks(req: Request): Response = {
val calculation = for {
_ <- withPermissions(req, someKindOfPermission)
json <- withJsonBody(req)
userId <- withJsonField[Int]("userId", json)
user <- withDBUser(userId)
} yield Response(200, user.toJson)
calculation.result
}