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?
- Self documenting code.
- Clearer intention of classes.
- More robust error handling at view level.
- Overall slightly neater.
- Built into language for a reason.
- May as well use it
- More helpful in SwiftUI than UIKit.
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.