Benefits of using throwing functions (try) - Swift's most underrated feature?

Benefits of using throwing functions (try) - Swift's most underrated feature?

I've always found throwing functions (try/catch, or do/catch) to be a very underrated feature in the Swift community. While many native APIs from iOS rely on it, very few people seem to actually use them in their daily projects. Most people seem to either optionally unwrap them (try?) or use other features like the Result type, and in my opinion, this stems from the fact that unless your entire project is built around throwing, the parts that do use it are somewhat annoying to deal with, and don't look very nice code-wise:

func getAValue() -> Int? {
    do {
        let content = try getAnInteger()
    } catch {
        return nil
    }
}

I've personally avoided this feature a lot for these reasons in favor of things like Promises or the new Result<> type, but even then, I still felt that my way of handling errors wasn't good. In fact, I was often falling into the same pitfalls, just in different ways. When I recently refactored one of my open-source CLI tools, I noticed that parts of it would not only look better if they used throwing functions instead, but they also would be considerably easier to unit test. I decided to try it out by refactoring that tool to use this feature, but to avoid falling into that same pit, I made the entire tool rely on it.

The results exceeded all of my expectations. With about ~80% of code coverage, the tool is now very easy to maintain and evolve thanks to the benefits of throwing functions. In this article, I've separated some of the benefits of this feature that have drawn my attention the most and shared some of my thoughts on why you should give it a second chance.

Throwing functions clean your code by allowing you to focus on what matters

When you need the value of something wrapped in the Result type, you must switch its result immediately. This will make your method responsible for handling any errors associated with that result:

func getUserProfile() -> Result<Profile, Error> {
    let result = database.profileSchema
    switch result {
    case .success(let profileSchema)
        let profile = Profile(profileSchema)
        return .success(profile)
    case .error(let error)
        return .error(error)
    }
}

This method now has two responsibilities which must ideally be covered by unit tests, which might not be your intention.

When you use try in a method that is itself throws, you can delegate the treatment of errors to the code that is actually interested in it. This allows you to develop methods that only handle their success cases:

func getUserProfile() throws {
    let profileSchema = try database.profileSchema
    return Profile(profileSchema)
}

As you can see, I don't need to worry about the database failing to fetch the user's profile schema in this specific method because that's not the point of it -- someone else will handle it if it happens. With these changes, this method is now so short that it possibly doesn't even need to exist anymore -- it could be refactored to a simple `try Profile(database: database)` call (initializers can also be throws!). This benefit is especially visible when your access depends on multiple things that can fail:

func migrateDefaultsToDatabase() throws {
    let oldUserProfile = try defaultsWrapper.profileJSON
    let converted = try databaseConverter.convertToDatabaseFormat(oldUserProfile)
    try database.set(converted, to: .profile)
}

Without throwing functions, you would probably have to divide this operation into multiple methods.

Throwing functions allows you to better design / unit test fatal problems

In CLI tools, it's common to halt everything or cause a crash when something goes wrong, like failing to open a file:

func obfuscate(file: File) {
    guard let contents = open(file) else {
        preconditionFailure()
    }
    let obfuscatedVersion = obfuscate(string: contents) // can also crash internally!
    guard success = save(contents, toFile: file) else {
        preconditionFailure()
    }
}

Not only this method is impossible to unit test by itself, but other unit tests might also trip these failure conditions and crash your test bundle entirely. While your iOS app probably doesn't crash in these conditions, I have seen my share of similar conditions: using an optional try?, returning things like nil or an empty string, logging the occurrence and having the methods who rely on this information be able to treat these special cases. This can be made better by using Result, but that can make you fall back to the previous issue: your methods now do more than they have to.

Similar to the previous benefit, you can use throws here and defer the actual crash / failure to someone who is actually interested in it. In the case of my CLI tool, all fatal conditions will throw a special FatalError, which only results in a crash if handled by main.swift. In fact, main.swift is the only part of the code that even attempts to handle errors. Everything in the tool is delegate to it, which made the tool's code considerably cleaner.

func obfuscate(file: File) throws {
    let contents = try open(file)
    let obfuscatedVersion = try obfuscate(string: contents)
    try save(contents, toFile: file)
}

You can now unit test that this method succeeds if everything is fine and proceed with your life. It's not necessary to unit test this method's specific failure conditions because the errors are not only not coming from it, it also doesn't handle them -- it just sends them downstream.

For reference, here's an example of a method in my CLI tool that generates a failure condition:

public func deobfuscate(crashFilePath: String, mapPath: String) throws {
    let crashFile = File(path: crashFilePath)
    let crash = try crashFile.read()
    let mapString = try File(path: mapPath).read()
    guard let map = ConversionMap(mapString: mapString) else {
        throw logger.fatalError(forMessage: "Failed to parse conversion map. Have you passed the correct file?")
    }
    let result = replace(crashLog: crash, withContentsOfMap: map)
    try crashFile.write(contents: result)
}

Custom errors can be made by creating enums that conform to Error. Personally, I like making my custom error inherit from LocalizedError to make error.localizedDescription return a custom description. This can be done by implementing its errorDescription property. (implementing localizedDescription directly doesn't work)

public enum SwiftShieldError: Error, LocalizedError {
    case fatal(String)

    public var errorDescription: String? {
        switch self {
        case .fatal(let message):
            return message
        }
    }
}

public func fatalError(forMessage message: String) -> Error {
    SwiftShieldError.fatal(message)
}

XCTestCase has special support for throwing methods

Perhaps my favorite benefit is that XCTestCase can automatically handle failures in throwing functions. Here's a classic example on how would I unit test something that used Result:

func testSomethingUsingResult() {
    let result: Result<String, Error> = getAResult()
    guard let string = result.get() else {
        XCTFail()
    } // You could also use the new XCTUnwrap here.
    XCTAssertEqual(string, "aString")
}

Having to bypass error conditions tests is very annoying. Fortunately, XCTestCase allows you to mark any test method as throws, making it automatically fail the test if it throws. By making the example's getAResult() become a throwing getAString() instead, you can refactor this test to a single line and completely ignore the failure conditions.

func testSomethingUsingTryCatch() throws {
    XCTAssertEqual(try getAString(), "aString")
}

If you would like to do the reverse, which is testing if something fails, there's no need to switch the result -- you can use the special XCTAssertThrowsError method. You can also use XCTAssertNoThrow to test that something succeeds when the result itself isn't what is being tested.

In general, what I like about this is that I don't need to consider failure cases when the test subject itself isn't the one throwing the errors. If I want to test that this method is working, all I have to do is test its success cases. If anything fails upstream, the test will throw an error and fail. This makes unit testing considerably easier and faster, while still being very durable (if not more durable, in my opinion).

Result can be translated from/to throwing functions

Although Result is sometimes seen as the opposite to throwing functions, they are actually somewhat interchangeable. It's possible to build Result types from throwing operations and get throwing operations from existing Result instances, which might be helpful if you'd like to play with throwing functions in a project without fully commiting to it.

let result: Result<String, Error> = Result(catching: { try file.read() })
let contents = try result.get()

Conclusion

Using throwing functions has been extremely beneficial in my new project, but as I said in the beginning, you might find that most of these benefits only apply if your project is completely using them to handle errors. Still, even if you don't have a full project, you can use Result's special initializers to treat the gaps and benefit from cleaner methods and more durable unit tests.

The project in question is SwiftShield. Make sure to check it out (especially the test cases!) to see how these benefits are translated to code.