Lessons in Swift Error Handling and Resilience

Introduction

To start, I’d like to note that I’m not using the word error in this talk to describe error types. Instead, it’s to encompass anything in your code that is wrong, or not on the ideal code path.

In development, we want code that is stable, behaves correctly, and does what we intend it to do. This allows us to continue delivering new features and improvements at a fast pace. We also want to anticipate any “unexpected errors” and minimize the impact this has on the user.

For example, if you aren’t able to read part of the user’s data from the database, we wouldn’t want to just crash and just give up. We also don’t want to resort to having the user contact customer service support, only to be asked to reinstall the app. Not only is this an awful user experience, it can mean users losing their own data, and their trust in the app.

This is what I’d like to cover to achieve the above:

  • How to write maintainable code
  • How to write testable code
  • What’s our goal during the correct behavior?
  • Does it become incorrect in the future?

Input

In order to write correct code and prevent errors from occurring, we need to be able to handle all input. Inputs can be reduced into two types:

Get more development news like this

  • Explicit
  • Implicit

Explicit input, by nature of being explicit, will stand out to the reader and more clearly communicate intent, and be the parameters to your functions.

Implicit input is any other data accessible to the function; it’s usually referred to as state.

In this very simple example, the parameter id would be explicit and settings would be implicit as it’s not a part of the method signature.

struct Foo {
	func bar(id: String) -> String {
		return settings[id] ?? ""
	}
}

State

A state typically consists of variables and constants at global scope. Any outer scope accessible to a function can be a potential source of state, such as singletons. If your function depends upon the values of any of these, the output can differ even with the same explicit input.

Functions that don’t depend on or affect the state of varying degrees are known as pure functions.

A temporal state is the state of the program being executed at any time. This is more of an issue when dealing with concurrent programming, but it’s a usable concept here. A temporal state is partially defined by the order of statements in imperative code, and because the order of code tends to be taken for granted, it’s classified as mostly implicit.

The order code needs to be run can be made more explicit. The most basic way to accomplish this is to write a comment. For example, “A must be called before B”. But if you want to communicate this to the compiler as well, you should make your code not compilable when rearranged in any other order.

Here is an example with Grand Central Dispatch. This will execute the print statements independently of each other without a defined order:

let queue = DispatchQueue.global()
queue.async { print("1") }
queue.async { print("2") }
print("3")  

You can think of the possible states of execution order being multiplied as the threads of execution interweave each other in different patterns. The print statements share the same serialized output. Because there are three places to fill, it’s three factorial, or otherwise six potential outcomes.

Here is another example without the concurrency:

func f() -> Int {
  let a = foo()
  let b = bar("b")
  doSomething()
  let = c bar(a)
  return b + c
}

Each statement here is evaluated in a top-down order, and there is a relationship enforced here by the compiler. I cannot place the line for let c first even if I wanted to because we don’t have the value of a yet.

Real world state/input

A “real-world” state or input isn’t a distinct type of state/input but rather describes a property of it. For example in a database, we may assume a field only has non-negative numbers because we made sure not to insert a negative value. But at one point someone may have stored a -1 or a null to indicate that the data was out of sync.

You can see how easily this would get out of hand. Any changes to code handling this kind of state shouldn’t be modified haphazardly.

Real world/state example bug

In the Line app, there is a feature called Theme. It lets the user change the look of the app. My task was to migrate the string format of the setting that holds which Theme is currently in use by the user.

import foundation
	
final class ThemeSubsystem {
	init() {
		let theme = UserDefaults.standard.string(forKey: "theme")
		// ...
		}
}

I soon found there is a problem with this code. The theme subsystem was inadvertently being initialized before my migration code because the new version of this code expects the newer format if it failed to initialize correctly with the value from user defaults.

However, once the migration code ran, subsequent launches found the setting and it initialized properly. The bug was created because of the shared state and ambiguous temporal relationship or dependency between the migration and the theme subsystem initialization.

How do we go about fixing this? One way is to make the relationship between the two explicit at compile time using the type system. In other words, if you don’t have an instance of a type, you cannot call a function that requires it as input.

Using this, we can create types that represent a transition in the state. Requiring instances of those types as parameters to our functions allows us to specify the state as a prerequisite to calling a function.

A basic way to implement this fix would be by creating a setup complete type and adding it to the initializer’s parameters. All that’s left is to create this instance of SetupComplete only when that state occurs, then passing it along.

import Foundation
final class ThemeSubsystem {
	init(_: SetupComplete) {
	let theme = UserDefaults.standard.string(forKey: "theme")
	// ...
	}
}

Error vs Optional

I’d like to switch gears and talk about the actual error type in Swift. More specifically, when to use the error type over an optional.

An optional type typically represents a right or wrong value or the presence or absence of a value. When used as input, it tends to represent optional semantics, as the name implies. Swift’s syntax makes it fairly easy and straightforward to safely handle these optionals.

The error type has similar semantics of “wrong” or unable to complete successfully. I find the obvious and most common use of error types is for fatal or catastrophic errors. Well known examples of these would be trying to connect to a corrupt database or a failed IO.

I find it helpful to use Swift’s error type to handle problematic situations that had a low probability of occurring. As an example, I’ve written a struct to represent ChatIDs in a specific string format. The struct validates the string and can provide some additional information on the type of chat it represents.

At first, I represented this in code as a struct with a failable initializer. Then, I decided to use try-statements so that I could provide custom errors to ease troubleshooting later. Only knowing why I couldn’t parse a ChatID string wasn’t good enough. I wanted to know the overall context in which this occurred for error monitoring purposes. This is the type of situation that I feel the error type is best suited for.

Conclusion

Be cognizant of input/output flow, as knowing its source helps in judging what’s important to need more explicit modeling. This will lead to code that is more robust.

When dealing with problematic input, if the problem is generic to the point that you can’t provide diagnostic information of any use, use an optional. But if there are several failure points or you want to add more context, using error propagation with your catch statement works well.

Next Up: New Features in Realm Obj-C & Swift

General link arrow white

Christopher Rogers

Christopher Rogers is an iOS developer for LINE, and is based out of Toyko, Japan.

Transcribed by Joseph Buelow