iOS Bite: Error Handling in SwiftUI

By Alexander Scanlon

May 22, 2024 at 09:30

ios-bites ios swift errors

Recap on Swift Error Syntax

A brief recap on throwing errors in Swift

Since Error is a protocol (aka interface), it can be adopted by classes, structs, and enums.

The easiest way to create a new error is to use an enum as seen here. Additional data can be added easily to the enum, such as a errorCode var.

enum BookFetchingError: Error {
    case notLoggedIn
}

Note: the https://developer.apple.com/documentation/swift/error protocol only has one element: localizedDescription. This var is computed for us but often it is better to add our own localized description (not shown here).

We label any function capable of throwing an error with the throws keyword in the function definition

func fetchBooks() async throws -> [Book] {
    guard isLoggedIn else {
        throw BookFetchingError.notLoggedIn
    }

    // ...
}

You can also see the try keyword here, used when calling functions that throw an error.

Try! and Try? are also available, but I would suggest against using them.

do {
    let books = try await fetchBooks()
} catch {
    // Do something with error
}

do {
    let books = try await fetchBooks()
} catch BookFetchingError.notLoggedIn {
    // Log in and try again
} catch {
    // Do something else with other errors
}


let books = try? await fetchBooks()
// Optional<[Book]>

Common Pattern of Swallowing Error Early

Here is a very common pattern in iOS development.

The fetchBook func has a dependency on the device having wifi, so when the device doesn’t have wifi the function returns nil.

This makes sense, as the function returns an Optional<Book>, meaning it failing and returning nothing is expected.

actor NetworkController {
    func fetchBook(id: String) async -> Book? {
        guard hasWifi else { return nil }

        // ...
    }
}

However, a major drawback of this pattern is that information that may be valuable to the caller is instead swallowed silently as an error, and now the class calling fetchBook doesn’t know WHY no book was returned

class ViewModel: ObservableObject {
    @Published var loadedBook: Loadable<Book> = .notRequested
    
    func fetchBook(id: String) async {
        guard let book = await self.networkController.fetchBook(id: id) else {
            MainActor.run { self.loadedBook = .error(?)  } // Why no book?
            return
        }

        // ...
    }
}

Letting Errors Bubble Up

Instead what I would suggest is letting the error bubble up, and let the caller of fetchBook worry about whether or not they care about the error thrown.

class ViewModel: ObservableObject {
    @Published var loadedBook: Loadable<Book> = .notRequested
    
    func fetchBook(id: String) async {
        do {
            let book = try await self.networkController.fetchBook(id: id)
            // ...
        } catch {
            MainActor.run { 
                // Why no book
                self.loadedBook = .error(error)  
            } 
        }
    }
}

This stops NetworkController from deciding what information is important and which isn’t.

actor NetworkController {
    enum NetworkError: Error {
        case noWifi
    }
    
    func fetchBook(id: String) async throws -> Book {
        guard hasWifi else { 
            throw NetworkError.noWifi 
        }
        // ...
    }
}

More importantly, it closer aligns with the intent of the fetchBook func, meaning it’s easier for any new dev to see this and know what it can do and what it might do.

Showing Error in SwiftUI View with Binding

To display an Error in SwiftUI we must first convert to a Bool.

If we have an error we would like to present an alert, or screen, or view, so in our Binding.get we return recorderViewModel.error != nil aka if we have an error return true.

Then we need a way to clear the error when a value of false is set using Binding.set, so we set recorderViewModel.error to nil when newValue is false.

class RecorderViewModel: ObservableObject {
    @Published var error: Error? = nil
    // …
}


private var errorBinding: Binding<Bool> {
    Binding {
        return recorderViewModel.error != nil
    } set: { newValue in
        if newValue == false {
            recorderViewModel.error = nil 
        }
    }
}
var body: some View {
    content
        .navigationTitle("Record")
        .alert("Error", isPresented: errorBinding) {
            // Nothing
        } message: {
            Text(recorderViewModel.error?.localizedDescription ?? "Unknown error")
        }
}

Showing Error in SwiftUI View with Loadable enum

An example of showing an Error using Loadable:

enum Loadable<T> {
    case notRequested
    case loading
    case error(Error)
    case loaded(T)
}
var book: Loadable<Peripheral> = .notRequested

var body: some View {
    switch book {
    case .notRequested: // ...
    case .loading: // ...
    case .error(let error): // ...
    case .loaded(let book): // ...
    }
}

But why?

The better you define your API, the easier it is to build the app. Every developer is an API developer.

Far and away the biggest reason to start throwing more errors in my opinion is it allows for cleaner code, and clearer intention of classes. It helps enforce a separation of concerns and helps enforce thinking about API design

Define your API correctly and the rest will follow.

References

Last updated: May 22, 2024 at 09:30