Figure

A blog about Swift and iOS development.

Moving Safety into Types

Let's say we have a truck. We want it to be safe, so we give it some seatbelts and airbags.

struct Truck {
  var seatbeltFastened: Bool
  var airbagsEnabled: Bool
}

And because we care about safety, we don't want to let the truck drive unless the seatbelts are fastened.

extension Truck {
  func drive() throws {
    guard seatbeltFastened else {
      throw SafetyError.seatbelt
    }
    guard airbagsEnabled else {
      throw SafetyError.airbag
    }
    // drive away...
  }
}

Now we can drive with confidence:

do {
  myTruck.drive()
} catch {
  print("Safety violation! Driving disabled!")
}

This is pretty nice. To be safe, we have to verify the seatbelt is fastened and the airbags are enabled before we drive. By putting the check in drive() itself, we don't have to remember to make the check every time we use the truck. And throws gives us a handy way to recover from any exceptional situations where driving isn't safe.

Here's the thing about throws, though; it tends to leak up into abstractions built on top of it.

Let's say we're building a shipping API, for example. We might want to ship a package via a truck:

func ship(package: Package, truck: Truck) throws {
  truck.add(package)
  try truck.drive() 
}

Our ship(package:, truck:) function looks pretty clean, but note that it's marked throws. Some logic around how our truck drives has leaked up into our shipping logic, forcing us to deal with it whenever we ship:

do {
  ship(package: myPackage, truck: myTruck)
} catch {
  //???
}

This exposes a few problems:

  • We're just trying to ship a package. We shouldn't have to manage trucks to do that.
  • Even if we wanted to manage trucks, it's not clear from calling ship() that any errors will come from driving or that the solution might be to, say, fasten seatbelts.1
  • We have to write this error checking code every time we ship something — even when using the same truck!

We want our truck to be safe. But if we validate its safety when we use it, we leak details up to anything that calls it, limiting its composability.

This is an example of complected concerns. We have two concepts here, driving and safety. We have to pull the two apart:

extension Truck {
  func validateSafety() throws {
    guard seatbeltFastened else {
      throw SafetyError.seatbelt
    }
    guard airbagsEnabled else {
      throw SafetyError.airbag
    }
  }

  func drive() {
    // just drive...
  }
}

This is cool in the sense that, having separated our validation from action, ship(...) no longer has to care about the state of the Truck we pass it:

do {
  try myTruck.validateSafety()
  ship(package: myPackage, truck: myTruck)
} catch {
  print("Safety Violation!")
}

But now we have to remember to check the safety of our truck every time before we use it! If we forget once, disaster.2

So what if instead of verifying safety in a method, we make safety a feature of a type?

struct SafeTruck {
  let value: Truck

  init(_ truck: Truck) throws {
    try truck.validateSafety()
    value = truck
  }
}

We've taken Truck and wrapped it in a new type, SafeTruck, which can only be created with a Truck that meets its safety requirements.

Which means we can now rewrite ship(package:, truck:) to take a SafeTruck instead of a Truck:

func ship(package: Package, truck: SafeTruck) {
  truck.value.add(package)
  truck.value.drive()
}

Now Swift does all the work for us:

ship(package: myPackage, truck: myTruck)
//🛑 Cannot convert value of type 'Truck' 
//   to expected argument type 'SafeTruck'

This isn't magic. We haven't somehow abstracted away the need to catch validation errors. We've just moved the implicit check we'd previously made whenever we used a truck into an explicit check we make on initialization:

do {
  let safeTruck = try SafeTruck(myTruck)
} catch {
  print("Truck is not safe!")
}

First, note that by moving validation from a thing that happens on use (where it could be buried beneath twelve layers of abstraction) to something that happens on creation, we've front-loaded it. We now get to handle errors where we have the most specific knowledge about them. To put it another way: there's little doubt why try SafeTruck(myTruck) might fail.

But we've also isolated our checks. We only have to write try...catch once (on initialization). After that (if our use case permits) we can reuse our safe, validated truck without having to recheck its safety.3

And because we've made safety a feature of our type, we have all the brains of Swift's tireless type checker behind us, making sure we never make a mistake.

After all, when given a type safe language it only makes sense to put safety in types.


1: True, we can deduce this information by matching for a specific error (SafetyError.seatbelt in this case). But knowing the specific error requires we know the implementation of ship(...) well enough to know what methods on Truck get called — and then know Truck well enough to know which methods throw and why.↩ī¸Ž

2: Whereby "disaster" I mean "a bug".↩ī¸Ž

3: Note this is only true because Truck is a value type. If SafeTruck stored a reference to a truck instead of a value, truck could be mutated behind its back. There'd be no way to guarantee a truck that was safe at initialization would still be safe later when actually used.↩ī¸Ž


Hit me up on twitter (@jemmons) to continue the conversation.

Latest | Archives