/ SWIFT

Swift Error Handling Strategies: Result, Throw, Assert, Precondition and FatalError

Swift has rich language features for propagating and handling errors. Picking the right way of failing contributes to code quality and makes programmer intention more clear. The goal of this articles is to demonstrate which mechanisms we have at our disposal and when to use what.

This article is not an introduction to the subject. If you want to familiarize yourself with the basics, I recommend checking Apple’s Error Handling tutorial first.

Categories of Swift Errors

Error is an unexpected condition, occurred during program execution. Based on their source, errors can be divided into two categories:

  • Logical, which are the result of programmer mistake; e.g. index out of bounds.
  • Runtime, which are outside of programmer control; e.g. file not found.

Error handling is the process of responding to and recovering from error conditions in your app [1].

The way we react to errors can be recoverable and non-recoverable. According to Swift Error Handling Rationale, the general advice is:

  • Logical error indicates that a programmer has made a mistake. It should be handled by fixing the code, not by recovering from it. Swift offers several APIs for this: assert(), precondition() and fatalError().
  • Runtime errors typically mean unexpected behavior of an operation. In this case termination is too extreme. Instead, we must recognize the possibility of error outcome and pick the most appropriate recovery strategy. This often boils down to propagating an error to a point where we can handle it the most practically, i.e. the user interface. For this purpose we can use: Optional<T>, Result<T> and throw.

Picking specific strategy is often guided by the author preference and different choices might be applicable in different situations. To leave less room for programmer bias, let’s categorize errors by their severity and provide usage guidelines.

Levels of Errors

Swift errors can be divided into levels from the least critical to the most:

Kind Recovery
Optional<T> Deal with nil.
Skip.
Result<T> Deal with an error.
Skip.
throw catch to deal with an error.
Skip with try?.
Assert no error with try!.
assert() Fallback for production.
precondition() None
fatalError() None

Our further discussion will be organized around this table. Let’s review each error level and explain when it’s best applied.

Optional

Swift Optional type represents non-error result that is either a value or the absence of a value.

Usage

Optionals are best suited for simple errors, where more robust error handling strategy will bring unnecessary complexity. For instance, when converting a string to an integer: Int.init?(_:).

Consider using Optional when:

  • An error is simple.
  • Both value and the absence of a value are valid and expected outcomes.

💡 Tip: do not return Optional collections and booleans. This introduces a degenerate case, where it’s ambiguous what means the absence of a value: is it an empty array or nil array; is it false or nil?

Result

Result represents either a success with a value or a failure with an error. Unlike Optional, Result provides more context on what caused the failure by carrying an Error-conforming type. Associated errors and values can be conveniently transformed by means of map() and flatMap() methods.

Usage

Result is primarily applied for asynchronous completion handlers. This task cannot be accomplished with any other mechanism: Optional lacks typed error, throw is synchronous.

Consider using Result when:

  • An error is propagated asynchronously, e.g. in completion callback.
  • Multiple error states are possible.
  • Both a value and an error are valid and expected outcomes.

Throw, Try and Catch

The throw expression propagates an Error-conforming type. Throwing an error indicates that something unexpected has happened and the normal flow of execution can’t continue. It automatically passes control to the first appropriate catch clause.

A method that may fail must be called with a try keyword. Method calls with try must be wrapped in a do clause, followed by one or more catch clauses. The catch clause can be specialized to handle a group of errors by including a pattern. The patterns must be organized from the least specific to the most:

do {
  try fooThatThrows()
} catch URLError.badURL {
  // Catch only Bad URLs
} catch {
  // Catch everything else
}

Usage

Consider throw-try-catch when:

  • An error cannot be handled locally inside a method.
  • An error is propagated synchronously.
  • Control flow needs to be changed.
  • Multiple error states are possible.
  • Multiple errors are handled from a single place (the catch clause).

Assertion

Assertion tests for conditions that should never happen if your code is correct. It is active only in development mode and is skipped in production.

Assertions allow to track down false assumptions during development, so that they can be fixed early. Using any other mechanism from the previous levels (throw, Optional or Result) for this purpose will be a mistake, since they represent a condition that the program has to recover from at runtime.

While we still have to provide a fallback for an assertion in production, I recommend against over-engineering it. I suggest sending error details to your crash reporting or analytics system to be able to fix it in your next release.

Usage

assert() is well-suited to tracking down programmer mistakes that should be fixed in code, but not recovered from. Use assert() to check your code for internal errors that are unexpected, but not fatal.

Precondition

Precondition works identically to assertion, except it terminates the app both in development and production, if its condition evaluates to be false.

Usage

Precondition is applicable to validating contracts of your public APIs. Use precondition() when:

  • You want to validate arguments, provided by your clients. E.g. in scripts, command line tools.
  • An error puts your app to corrupted state, where there is absolutely no way to proceed execution.

Assert vs Precondition

Since assert() and precondition() are very similar, here is some rational to distinguish between the two:

  • Use assert() to check your code for internal errors.
  • Use precondition() when consuming arguments from your clients. These parameters require public documentation.

Fatal Error

The fatalError() method terminates your app unconditionally. It should only be used for errors that put an app in such a corrupted state that it cannot proceed, so that the termination is the only reasonable action. Additionally, fatalError() returns Never, so that you can use it for methods when you have nothing to return.

💡 When considering between fatalError() and precondition(), take into account that the former adds failure message to the crash report, while the latter doesn’t.

Usage

I recommend against shipping code with fatalError() to real users. Personally, I am using it during development when there is nothing to return from a method, e.g. table view cell is not registered in tableView(_:,cellForRowAt:). No matter what, you don’t want your app to crash in production.

Use fatalError() only during development when:

  • An error is critical.
  • You have nothing to return from a method.

Summary

Swift error handling strategies can be categorized into:

  • recoverable: Optional, Result, throw;
  • and non-recoverable: assert(), precondition(), fatalError().

Non-recoverable strategies are generally more appropriate for dealing with logical errors; recoverable are for runtime errors. Picking the most sensible strategy contributes to code quality and makes programmer intent more clear.

Further Reading

If you want to learn more about Swift code quality, I’ve been writing on the topic: