Error Handling in Swift: Might and Magic—Part II
In my previous post, I showed that defining a map
method on the Result
enum allows chaining of a series of transformations to a result without caring whether the result was a success or a failure until the value was needed for output. I pointed out at the end, though, that there was a problem with certain types of methods. I’ll address that issue here.
• • • • •
A brief recap. The map
method allows chaining any single-input-single-output method on a result, like so:
var finalResult = someResult.map(f1)
.map(f2)
.map(f3)
The outcomes are what we would expect:
- If
someResult
isResult.Success(a)
,finalResult
will beResult.Success(f3(f2(f1(a))))
. - If
someResult
isResult.Failure(someString)
,finalResult
will also beResult.Failure(someString)
.
In other words, the failure is propagated. This is awesome, and works great with error-proof computations, such as additions, multiplications, array length, etc. But what happens if one of the computations can fail? In the example I nonchalantly had:
let result = divide(a, by:b)
let logResult = result.map(lowestPrimeFactor).map(log)
However, a realistic log
function would be declared as func log(num:Float) -> Result<Float>
. It would return a Result
because log
is undefined for negative numbers. So if I were to apply map
as above, I’d end up with this monster as my return type: Result<Result<Float>>
. I could then only call map
with functions that took a Result<Float>
as a parameter, completely breaking my abstraction.
Clearly, map
is not up to the task. What I need is a second method, similar to map
, but that will know how to funnel, so to speak, a Result
through a function that takes a value but returns a Result
. I need this:
extension Result {
func flatMap(f:T -> Result<P>) -> Result<P> {
switch self {
case Success(let value):
return f(value)
case Failure(let errString):
return Result.Error(errString)
}
}
}
Again, a very simple implementation:
- If the current result is a
Success
, applyf
and return its return value. - If the current result is an
Error
, wrap the string and return it.
It’s actually even simpler than map
, despite serving a more complex case. Looking back at the first example, if f2
can return an error, the could turns into:
var finalResult = someResult.map(f1)
.flatMap(f2)
.map(f3)
The outcomes are a bit more complex but make every bit as much sense:
- If
someResult
was.Failure(someString)
,finalResult
will also be.Failure(someString)
. - If
someResult
was.Success(someValue)
:- If
f2(f1(someValue))
does not return a failure,finalResult
will be.Success(f3(f2(f1(someValue))))
. - If
f2(f1(someValue))
returns.Failure(someOtherString)
,finalResult
will be.Failure(someOtherString)
.
- If
In short, things will be exactly as we would expect them to be: the error that breaks the computation is propagated, and if no error occurs, the computation returns a valid result. For reference, this is what the magic spell example from my previous post should actually look like:
let theMagicSpell = divide(2.5, by:3).map(findLeastPrimeFactor)
.flatMap(log)
.map(spellIdentifier)
.map(incantation)
So I can mix and match map
and flatMap
calls as needed by the functions I want to apply—and I still keep the benefit of delayed error handling.
• • • • •
These two posts have dealt with synchronous error handling. The truth is, though, that a good chunk of error-prone code is nowadays asynchronous; we try to maintain UI responsiveness by throwing as many things as possible on background threads, and it’s usually network or file IO issues that break things, which typically take callback blocks. In a later post I’ll take a look at approaches for dealing with that.
Update: I changed the name of the function introduced in this article to flatMap
from funnel
, after discussing with [@cocoaphony][] and [@lightfiend][] the pros and cons of the name and deciding that my dislike of flatMap
is less well-founded than I initially felt.
[@cocoaphony]: https://twitter.com/cocoaphony
[@lightfiend]: https://twitter.com/lightfiend