Result Oriented Programming: building a result incrementally using function composition
At Standard Bank, we use the Result type. We use a rather simple implementation, which can be digested quite easily:
enum Result<T, Error: Swift.Error> {
case success(T)
case failure(Error) func map<U>(_ transform: (T) -> U) -> Result<U, Error> {
return flatMap { .success(transform($0)) }
} func flatMap<U>(_ transform: (T) -> Result<U, Error>)
-> Result<U, Error> {
switch self {
case let .success(value): return transform(value)
case let .failure(error): return .failure(error)
}
}
}
This allows us to adopt the Railway Oriented Programming pattern by piping functions together in the following way:
let result = getUser()
.flatmap(getLatestTweet)
.flatmap(getTweetSentiment)
Result Oriented Programming \ Railway Oriented Programming is a powerful functional pattern that can truly simplify the way you write code. If you are unfamiliar with the pattern, I would strongly suggest you watch the videos below first:
Describing the problem
Imagine we have the following function signature:
(A) -> (B) -> (C) -> (D)
this is essentially:
(A) -> (D)
but what we actually want is:
(A) -> (B) -> (C) -> (D) -> (A, B, C, D)
Let us attempt to try solve this.
If you would like to follow with a working solution, please view the following playground file.
Building a simple example using functional composition
Let us build a ‘useful’ Twitter Sentiment Tool off a fictitious API. We will begin by firstly defining our data models:
struct User {
let id: String
let name: String
}struct Tweet {
let id: String
let message: String
let userId: String
}struct TweetSentiment {
let id: String
let isPositive: Bool
let tweetId: String
}
Because of the way the API is structured, in order to get the actual sentiment of a tweet, we would first need to make an API call to get the current user. We would then use the the user’s data to get their latest tweet. Finally we would use the tweet’s data to get the tweet’s sentiment.
We are not going to worry about the actual implementations for the functions below, instead let’s focus on their signatures. The function signature for getting a user:
func getUser(with userId: String) -> Result<User, Error>
for getting the latest tweet:
func getLatestTweet(for user: User) -> Result<Tweet, Error>
and finally for getting the sentiment of the tweet:
func getTweetSentiment(for tweet: Tweet)
-> Result<TweetSentiment, Error>
So essentially we can only retrieve the tweet’s sentiment, if we are first able to retrieve the current user and then their latest tweet. If at any point during the pipeline something were to go wrong, we stop and the error falls through. For example, if we had the following:
enum EmojiErr: Error {
case 😩(String)
}func getUser(with userId: String) -> Result<User, EmojiErr>
func getLatestTweet(for user: User) -> Result<Tweet, EmojiErr>
func getTweetSentiment(for tweet: Tweet)
-> Result<TweetSentiment, EmojiErr>
We achieve this:
let result = getUser()
.flatmap(getLatestTweet)
.flatmap(getTweetSentiment)switch result {
case let .success(value):
print("Positive tweet: \(value.isPositive)")
case let .failure(error):
switch error {
case let .😩(value): assertionFailure(value)
}
}
Awesome, exactly what we are looking for. Now let us design a new data model that ecapsulates a user, their latest tweet, and the tweet’s sentiment. This model will extract the information we need to give us a nice message about the sentiment of the tweet.
struct TweetDetails {
private let user: User
private let tweet: Tweet
private let sentiment: TweetSentiment init(user: User, tweet: Tweet, sentiment: TweetSentiment) {
self.user = user
self.tweet = tweet
self.sentiment = sentiment
} var expressedMessage: String {
let sentimentDescription = sentiment.isPositive
? "positive"
: "negative"
return "\(user.name) said \(tweet.message)
which has a \. (sentimentDescription) sentiment"
}
}
Ok now let us create a method that returns this model:
func getTweetDetails(for userId: String)
-> Result<TweetDetails, EmojiErr> {
return getUser()
.flatmap(getLatestTweet)
.flatmap(getTweetSentiment)
}
Unfortunately this will not work, because our return type is of Result<TweetSentiment, EmojiErr>
and should be of Result<TweetDetails, EmojiErr>
.
We could, however, solve this rather quickly with the following snippet:
func getTweetDetails(for userId: String)
-> Result<TweetDetails, EmojiErr> {
var user: User!
var tweet: Tweet!
return getUser(with: userId)
.flatMap { userData -> Result<Tweet, EmojiErr> in
user = userData
return getLatestTweet(for: userData)
}.flatMap {
tweetData -> Result<TweetSentiment, EmojiErr> in
tweet = tweetData
return getTweetSentiment(for: tweetData)
}.flatMap { sentimentData in
.success(TweetDetails(user, tweet, sentimentData))
}
}
But now we moving away from code that was succinctly declarative to something more imperative. Since we are working with a functional pattern, we can do better.
To solve this, we first need to ammend our TweetDetails
model to be built incrementally. We pass in the user, then the tweet, and finally the sentiment of the tweet.
func createTweetDetails(user: User) -> (_ tweet: Tweet) -> (_ sentiment: TweetSentiment) -> TweetDetails {
return { tweet in
return { sentiment in
return TweetDetails(user, tweet, sentiment)
}
}
}let result = createTweetDetails(user)(tweet)(sentiment)
In other words createTweetDetails is a curried function of TweetDetails.init
. Awesome. So let us rather create a generic curried function for this:
Currying is the technique of translating the evaluation of a function that takes multiple arguments (or a tuple of arguments) into evaluating a sequence of functions, each with a single argument. — Wikipedia
func curry<A, B, C, D>(_ function: @escaping (A, B, C) -> D) -> (A) -> (B) -> (C) -> D {
return { (a: A) -> (B) -> (C) -> D in { (b: B) -> (C) -> D in {
(c: C) -> D in function(a, b, c) } } }
}
Now let us update our implementation for getTweetDetails
:
func getTweetDetails(for userId: String)
-> Result<TweetDetails, HTTPError> {
let tweetDetails = curry(TweetDetails.init)
...
}
Now we are getting somewhere. So let us step back and unpack what we are trying to do from here:
- Firstly, we don’t ever want to change the signatures and implementations of our functions
getUser
,getLatestTweet
, andgetTweetSentiment
. - So we need to create a function, that takes a result, passes the result to our
tweetDetails
model, and finally returns both the result and the partial model into the next function. We do this until we have a completetweetDetails
model.
Let us create a generic function called applyResult
that does just this:
func applyResult<A, B>(of result: Result<A, EmojiError>, to partial: (A) -> B) -> Result<(result: A, partial: B), EmojiError> {
return result.flatMap { value in
.success((value, partial(value)))
}
}
Now let us go back to our implementation and update it:
func getTweetDetails(for userId: String)
-> Result<TweetDetails, HTTPError> {
let tweetDetails = curry(TweetDetails.init)
let result = applyResult(of: getUser(), to: tweetDetails)
...
}
In order to flatmap
correctly from here, we need to be able to define the next function that will need to be called to get the next result. We can do this by creating another generic function called applyResult
with a different signature:
func applyResult<A, B, C>(of fn: @escaping (C) -> Result<A, EmojiError>) -> (_ input: C, _ partial: (A) -> B) -> Result<(result: A, transfrom: B), EmojiError> {
return { input, partial in
return fn(input).flatMap { value in
.success((value, partial(value)))
}
}
}
Let us go back again and update our implementation:
func getTweetDetails(for userId: String)
-> Result<TweetDetails, HTTPError> {
let tweetDetails = curry(TweetDetails.init)
let result = applyResult(of: getUser(), to: tweetDetails)
.flatMap(applyResult(of: getLatestTweet))
...
}
Finally The last thing we need to do is return the completed model without the previous result, because at this point our model is complete. We can do this again by creating another generic function called applyResult
with the following signature:
func applyResult<A, B, C>(of fn: @escaping (C) -> Result<A, EmojiError>) -> (_ input: C, _ partial: (A) -> B) -> Result<B, EmojiError> {
return { input, partial in
return fn(input).flatMap { value in
.success(partial(value))
}
}
}
Which leads us back to the beginning:
func getTweetDetails(for userId: String) -> Result<TweetDetails, EmojiError> {
let tweetDetails = curry(TweetDetails.init)
return applyResult(of: getUser(), to: tweetDetails)
.flatMap(applyResult(of: getLatestTweet))
.flatMap(applyResult(of: getTweetSentiment))
}
Conclusion
When writing generic functions, we rely on the compiler quite heavily, especially when composing functions together. You work with the compiler to create your solution. Even though the functions are rather small, they are quite dense. It takes more time to think of declarative functional solutions as opposed to an imperative one, but you end up with code that is easier to read, test, and compose together. Eric Elliot summed this up quite nicely:
Less code = less surface area for bugs = fewer bugs.
You can find all the working code in the following repository.