Chapters

Hide chapters

Swift Apprentice: Beyond the Basics

First Edition · iOS 16 · Swift 5.8 · Xcode 14.3

Section I: Beyond the Basics

Section 1: 13 chapters
Show chapters Hide chapters

5. Error Handling
Written by Matt Galloway

Heads up... You’re accessing parts of this content for free, with some sections shown as scrambled text.

Heads up... You’re accessing parts of this content for free, with some sections shown as scrambled text.

Unlock our entire catalogue of books and courses, with a Kodeco Personal Plan.

Unlock now

Skilled developers design their software for errors. Error handling is the art of failing gracefully. Although you completely control your code, you don’t control outside events and resources. These include user input, network connections, available system memory and files your app needs to access.

In this chapter, you’ll learn the fundamentals of error handling: what it is and different strategies for implementing good error handling.

What is Error Handling?

Imagine you’re in the desert, and you decide to surf the internet. You’re miles away from the nearest hotspot with no cellular signal. You open your internet browser. What happens? Does your browser hang there forever with a spinning wheel of death, or does it immediately alert you that you have no internet access?

When designing the user experience for your apps, you must think about the error states. Think about what can go wrong, how you want your app to respond, and how to surface that information to users to allow them to act on it appropriately.

First Level Error Handling With Optionals

Throughout this book, you have already seen an elementary form of error handling in action. Optionals model missing information and provide compiler and runtime guarantees that you won’t accidentally act on values that are not available. This predictability is the foundation of Swift’s safety.

Failable Initializers

When you try to initialize an object from external input, it may fail. For example, if you’re converting a String into an Int, there is no guarantee it’ll work.

let value = Int("3")          // Optional(3)
let failedValue = Int("nope") // nil
enum PetFood: String {
  case kibble, canned
}

let morning = PetFood(rawValue: "kibble")  // Optional(.kibble)
let snack = PetFood(rawValue: "fuuud!")    // nil
struct PetHouse {
  let squareFeet: Int
  
  init?(squareFeetAsString: String) {
    guard let squareFeet = Int(squareFeetAsString) else {
      return nil
    }
    self.squareFeet = squareFeet
  }
}

let nopeHouse = PetHouse(squareFeetAsString: "nope") // nil
let house = PetHouse(squareFeetAsString: "100")      // Optional(Pethouse)

Optional Chaining

Have you ever seen a prompt in Xcode from the compiler that something is wrong, and you are supposed to add ! to a property? The compiler tells you you’re dealing with an optional value and sometimes suggests you deal with it by force unwrapping.

class Pet {
  var breed: String?

  init(breed: String? = nil) {
    self.breed = breed
  }
}

class Person {
  let pet: Pet

  init(pet: Pet) {
    self.pet = pet
  }
}

let delia = Pet(breed: "pug")
let olive = Pet()

let janie = Person(pet: olive)
let dogBreed = janie.pet.breed! // This is bad! Will cause a crash!
if let dogBreed = janie.pet.breed {
  print("Olive is a \(dogBreed).")
} else {
  print("Olive’s breed is unknown.")
}
class Toy {

  enum Kind {
    case ball, zombie, bone, mouse
  }

  enum Sound {
    case squeak, bell
  }

  let kind: Kind
  let color: String
  var sound: Sound?

  init(kind: Kind, color: String, sound: Sound? = nil) {
    self.kind = kind
    self.color = color
    self.sound = sound
  }
}

class Pet {

  enum Kind {
    case dog, cat, guineaPig
  }

  let name: String
  let kind: Kind
  let favoriteToy: Toy?

  init(name: String, kind: Kind, favoriteToy: Toy? = nil) {
    self.name = name
    self.kind = kind
    self.favoriteToy = favoriteToy
  }
}

class Person {
  let pet: Pet?

  init(pet: Pet? = nil) {
    self.pet = pet
  }
}

let janie = Person(pet: Pet(name: "Delia", kind: .dog, 
                   favoriteToy: Toy(kind: .ball, 
                   color: "Purple", sound: .bell)))
let tammy = Person(pet: Pet(name: "Evil Cat Overlord", 
                   kind: .cat, favoriteToy: Toy(kind: .mouse, 
                   color: "Orange")))
let felipe = Person()
if let sound = janie.pet?.favoriteToy?.sound {
  print("Sound \(sound).")
} else {
  print("No sound.")
}
if let sound = tammy.pet?.favoriteToy?.sound {
  print("Sound \(sound).")
} else {
  print("No sound.")
}

if let sound = felipe.pet?.favoriteToy?.sound {
  print("Sound \(sound).")
} else {
  print("No sound.")
}

map and compactMap

Let’s say you want to create an array of pets the team owns. First off, you need to create an array of team members:

let team = [janie, tammy, felipe]
let petNames = team.map { $0.pet?.name }
for pet in petNames {
  print(pet)
}
Expression implicitly coerced from 'String?' to 'Any'
Optional("Delia")
Optional("Evil Cat Overlord")
nil

let betterPetNames = team.compactMap { $0.pet?.name }

for pet in betterPetNames {
  print(pet)
}
Delia
Evil Cat Overlord

Error Protocol

Swift includes the Error protocol, which forms the basis of the error-handling architecture. Any type conforming to this protocol represents an error and can take part in error-handling routines you will learn about shortly.

class Pastry {
  let flavor: String
  var numberOnHand: Int

  init(flavor: String, numberOnHand: Int) {
    self.flavor = flavor
    self.numberOnHand = numberOnHand
  }
}
enum BakeryError: Error {
  case tooFew(numberOnHand: Int), doNotSell, wrongFlavor
  case inventory, noPower
}

Throwing Errors

What does your program do with these errors? It throws them, of course! That’s the terminology you’ll see: throwing errors and then catching them.

class Bakery {
  // 1
  var itemsForSale = [
    "Cookie": Pastry(flavor: "ChocolateChip", numberOnHand: 20),
    "PopTart": Pastry(flavor: "WildBerry", numberOnHand: 13),
    "Donut" : Pastry(flavor: "Sprinkles", numberOnHand: 24),
    "HandPie": Pastry(flavor: "Cherry", numberOnHand: 6)
  ]
  
  // 2
  func open(_ shouldOpen: Bool = Bool.random()) throws -> Bool {
    guard shouldOpen else {
      // 3
      throw Bool.random() ? BakeryError.inventory 
                          : BakeryError.noPower
    }
    return shouldOpen
  }

  // 4
  func orderPastry(item: String,
                   amountRequested: Int,
                   flavor: String)  throws  -> Int {
    // 5
    guard let pastry = itemsForSale[item] else {
      throw BakeryError.doNotSell
    }

    // 6
    guard flavor == pastry.flavor else {
      throw BakeryError.wrongFlavor
    }
    guard amountRequested <= pastry.numberOnHand else {
      throw BakeryError.tooFew(numberOnHand: 
                               pastry.numberOnHand)
    }
    pastry.numberOnHand -= amountRequested

    return pastry.numberOnHand
  }
}
let bakery = Bakery()
bakery.open()
bakery.orderPastry(item: "Albatross",
                   amountRequested: 1,
                   flavor: "AlbatrossFlavor")
Call can throw but is not marked with 'try'

Handling Errors

After your program throws an error, you need to handle that error. There are two ways to approach this problem: Immediately handling your errors or bubble them up to another level.

do {
  try bakery.open()
  try bakery.orderPastry(item: "Albatross",
                          amountRequested: 1,
                          flavor: "AlbatrossFlavor")
} catch BakeryError.inventory, BakeryError.noPower {
  print("Sorry, the bakery is now closed.")
} catch BakeryError.doNotSell {
  print("Sorry, but we don’t sell this item.")
} catch BakeryError.wrongFlavor {
  print("Sorry, but we don’t carry this flavor.")
} catch BakeryError.tooFew {
  print("Sorry, we don’t have enough items to fulfill your 
         order.")
}

Not Looking at the Detailed Error

If you don’t care about the error details, you can use try? to wrap the result of a function (or method) in an optional. The function will then return nil if an error is thrown within it. In this case, there is no need to set up a do {} catch {} block.

let open = try? bakery.open(false)
let remaining = try? bakery.orderPastry(item: "Albatross",
                                        amountRequested: 1,
                                        flavor: "AlbatrossFlavor")

Stopping Your Program on an Error

Sometimes you know for sure that your code is not going to fail. For example, if you know the bakery is now open and just restocked the cookie jar, you can order a cookie. Add:

do {
  try bakery.open(true)
  try bakery.orderPastry(item: "Cookie",
                         amountRequested: 1,
                         flavor: "ChocolateChip")
}
catch {
  fatalError()
}
try! bakery.open(true)
try! bakery.orderPastry(item: "Cookie", amountRequested: 1, 
                        flavor: "ChocolateChip")

Advanced Error Handling

Cool, you know how to handle errors! That’s neat, but how do you scale your error handling to a more extensive, complex app?

PugBot

The sample project you’ll work with in this second half of the chapter is PugBot. The PugBot is cute and friendly but sometimes gets lost and confused.

enum Direction {
  case left, right, forward
}
enum PugBotError: Error {
  case invalidMove(found: Direction, expected: Direction)
  case endOfPath
}

class PugBot {
  let name: String
  let correctPath: [Direction]
  private var currentStepInPath = 0

  init(name: String, correctPath: [Direction]) {
    self.correctPath = correctPath
    self.name = name
  }

  func move(_ direction: Direction) throws {
    guard currentStepInPath < correctPath.count else {
      throw PugBotError.endOfPath
    }
    let nextDirection = correctPath[currentStepInPath]
    guard nextDirection == direction else {
      throw PugBotError.invalidMove(found: direction, 
                                    expected: nextDirection)
    }
    currentStepInPath += 1
  }
  
  func reset() {
    currentStepInPath = 0
  }
}
let pug = PugBot(name: "Pug",
                 correctPath: [.forward, .left, .forward, .right])

func goHome() throws {
  try pug.move(.forward)
  try pug.move(.left)
  try pug.move(.forward)
  try pug.move(.right)
}

do {
  try goHome()
} catch {
  print("PugBot failed to get home.")
}

Handling Multiple Errors

You might benefit from a function that can move the PugBot and handle errors by reporting what went wrong. Add the following code to your playground:

func moveSafely(_ movement: () throws -> ()) -> String {
  do {
    try movement()
    return "Completed operation successfully."
  } catch PugBotError.invalidMove(let found, let expected) {
    return "The PugBot was supposed to move \(expected), 
            but moved \(found) instead."
  } catch PugBotError.endOfPath {
    return "The PugBot tried to move past the end of the path."
  } catch {
    return "An unknown error occurred."
  }
}
pug.reset()
moveSafely(goHome)

pug.reset()
moveSafely {
  try pug.move(.forward)
  try pug.move(.left)
  try pug.move(.forward)
  try pug.move(.right)
}

rethrows

A function that takes a throwing closure as a parameter has to choose: either catch every error or be a throwing function. Let’s say you want a utility function to perform a specific movement or set of movements several times in a row.

func perform(times: Int, movement: () throws -> ()) rethrows {
  for _ in 1...times {
    try movement()
  }
}
try? perform(times: 5) {
  try pug.move(.forward)
}

perform(times: 5) {
  pug.reset()
}

Throwable Properties

Types can have computed properties. Sometimes a computed property could fail to compute. In those cases, you want to be able to throw an error from the getter. This is possible only from read-only computed properties.

// 1
class Person {
  var name: String
  var age: Int
  
  init(name: String, age: Int) {
    self.name = name
    self.age = age
  }
}

// 2
enum PersonError: Error {
  case noName, noAge, noData
}
extension Person {
  var description: String {
    get throws {
      guard !name.isEmpty else {throw PersonError.noName}
      guard age > 0 else {throw PersonError.noAge}
      return "\(name) is \(age) years old."
    }
  }
}
let me = Person(name: "Alice", age: 32)

me.name = ""
do {
  try me.description
} catch {
  print(error) // "noName"
}

me.age = -36
do {
  try me.description
} catch {
  print(error) // "noName"
}

me.name = "Alice"
do {
  try me.description
} catch {
  print(error) // "noAge"
}

me.age = 36
do {
  try me.description // "Alice is 32 years old."
} catch {
  print(error)
}

Throwable Subscripts

You can also throw errors from read-only subscripts. Add the following code to your playground:

extension Person {
  subscript(key: String) -> String {
    get throws {
      switch key {
        case "name": return name
        case "age": return "\(age)"
        default: throw PersonError.noData
      }
    }
  }
}
do {
  try me["name"] // "Alice"
} catch {
  print(error)
}

do {
  try me["age"] // "32"
} catch {
  print(error)
}

do {
  try me["gender"] 
} catch {
  print(error) // "noData"
}

Challenges

Before moving on, here are some challenges to test your error-handling knowledge. It’s best to try and solve them yourself, but solutions are available if you get stuck. These came with the download or are available at the printed book’s source code link listed in the introduction.

Challenge 1: Even Strings

Write a function that converts a String to an even number, rounding down if necessary. It should throw if the String is not a valid number.

Challenge 2: Safe Division

Write a function that divides two Ints. It should throw if the divisor is zero.

Challenge 3: Account Login

Given the following code:

class Account {
  let token: String
}

enum LoginError: Error {
  case invalidUser
  case invalidPassword
}

func onlyAliceLogin(username: String, password: String) throws -> String {
  guard username == "alice" else {
    throw LoginError.invalidUser
  }
  guard password == "hunter2" else {
    throw LoginError.invalidPassword
  }
  return "AUTH_TOKEN"
}
let account1 = try? Account(username: "alice", password: "hunter2", loginMethod: onlyAliceLogin)

let account2 = Account(username: "alice", password: "hunter2") { _, _ in
  return "AUTH_TOKEN"
}

Key Points

  • You can make an initializer failable by naming them init? and returning nil if they fail.
  • A type can conform to the Error protocol to work with Swift’s error-handling system.
  • Any function that can throw an error, or call a function that can throw an error, has to be marked with throws or rethrows.
  • When calling an error-throwing function from a function that doesn’t throw, you must embed the function call in a do block. Within that block, you try the function, and if it fails, you catch the error.
  • try? lets you convert a thrown error into a nil return value.
  • try! lets you convert a thrown error to a fatal error that terminates your app.
  • Read-only computed properties and subscripts can be annotated to throw. To access these properties, the standard try-catch rules apply.
Have a technical question? Want to report a bug? You can ask questions and report bugs to the book authors in our official book forum here.
© 2024 Kodeco Inc.

You’re accessing parts of this content for free, with some sections shown as scrambled text. Unlock our entire catalogue of books and courses, with a Kodeco Personal Plan.

Unlock now