Ignoring Errors with Function Builders

I came across an interesting example of using function builders to capture thrown errors and thought someone else might be interested in seeing the example.

There are times where I'd like the program to continue even though errors are thrown, and just report what went wrong afterwards.

For example, suppose you're working on an app that has lots of initialisation steps: like resetting the app's local state, connecting to a datastore, or verifying configuration data.

struct App { func reset() throws }
struct DataStore { func connect() throws }
struct Configuration { func verify() throws }

let app = App()
let store = DataStore()
let config = Configuration()

let reset = ProcessInfo().environment["ShouldReset"] == "true"

do {
    if reset {
        try app.reset()
    }
    try store.connect()
    try config.verify()
} catch {
    print(error)
}

Now, the product manager comes up to you and says that the app must still function even if some part of it has stopped working. The problem with the above is that if resetting the app fails, then the app never connects to the datastore - more generally, subsequent code is not executed. If you wanted the functions in a do-catch block to continue executing and capture the errors, what would you do?

You could try catching all of the errors. This sounds like a tricky game of doge ball! :stuck_out_tongue:

var errors: [Error] = []

if reset {
    do { 
        try app.reset()
    } catch {
        errors.append(error)
    }
}

do {
    try store.connect()
} catch {
    errors.append(error)
}

do {
    try config.verify()
} catch {
    errors.append(error)
}

if errors.count > 0 {
    print(errors) // or present this in the gui somehow.
}

Function Builders to the Rescue.

We can create a function builder that captures throwing functions using an auto-closure, evaluates them, and throws a union of the resulting errors.

For example, using a function builder, we reduce the above into something like this:

do {
    try ignore {
        if reset {
           try app.reset()
        }
        try store.connect()
        try config.verify()
    }
} catch {
    print(error)
}

If you truely want execution to stop, then ignore the result of the function:

// Execution will stop because the result is not captured by the function builder.
// _ = try app.reset() does not work in the latest version of Xcode (12.3)
let x = try app.reset()

Warning: This will cause the ignore function to throw the error from the function before any captured methods are called.

The trick is to declare a buildExpression(_:) function that accepts an auto-closure throwing method:

@_functionBuilder
struct ErrorBuilder {
    typealias Method = () throws -> ()

    static func buildBlock() -> [Method] { return [] }
    static func buildBlock(_ methods: Method...) -> [Method]  { return methods }

    static func buildExpression(_ expression: @autoclosure @escaping Method) -> Method {
        return expression
    }
}

Then, implement the ignore(_:) function like so:

func ignore(@ErrorBuilder block: () throws -> [ErrorBuilder.Method]) throws {
    var ignoredErrors: [Error] = []
    let methods = try block()
    for method in methods {
        do {
            try method()
        } catch {
            ignoredErrors.append(error)
        }
    }
    if ignoredErrors.count > 0 {
        throw ignoredErrors
    }
}

// Alternatively use a custom type to pretty print the errors 
// and capture file and line numbers.
extension Array: Error where Element: Error { }

Hoped you enjoyed the read! :slight_smile: