As developers, we are often told that we should avoid crashing our apps at all costs. It's why we are told that we shouldn't force unwrap our optionals, that we should avoid unowned
references and that we should never use try!
in production code. In today's article, I would like to offer you a counter opinion on this never crash train of thought. I firmly believe that sometimes we should crash and that not crashing could sometimes be worse than crashing because it might leave your app in an unresponsive state, or maybe it hides a problem that you won't know about until your App is in the store and gets its first one-star reviews.
This article contains three sections:
- Understanding how your code can fail
- Crashing your app in production
- Crashing your app during development
It's important to make the distinction between crashing during development and in production because you'll often want to be a little bit less forgiving during development than you'd be in production. I'm going to show you several of Swift's built-in assertion mechanisms that you can use to verify that your app is in an expected state and can continue to function.
Understanding how your code can fail
There are many things that can go wrong in code. And there are many ways for code to communicate errors back to the call site. For example, a method might use a Result
object to specify whether an operation succeeded, or if an error occurred. This is common in asynchronous code where callback closure is invoked with a Result
.
When code executes synchronously, it's common for methods to be marked with throws
to indicate that the method will either complete successfully, return something that was requested or throw an error if something goes wrong. Both Result
and throw
force you to at least think of what should happen when an error occurs, and it's up to you to decide whether you can recover from that error and whether it makes sense to do so.
These two examples highlight cases where the code you're calling defers error handling back to you. Other code might be more opinionated on whether something is recoverable or not. Consider this example:
let array = [String]()
print(array[1])
The above code will crash in a Playground. The reason for this is that the subscript function on Array
in Swift considers accessing an index that is not in the array to be a programming error. In other words, they consider it a bug in your code that you should fix. This crashes both in production and development. In the Swift source code, you can see that Apple uses a preconditionFailure()
to signal that the error is not recoverable and that the program should be considered in an invalid state.
A very similar way to crash your app when something is wrong is when you use fatalError()
. While the result is the same (your app crashes), fatal errors should be used less often than precondition failures because they carry a slightly different meaning. A precondition failure is usually thrown in response to something a programmer is doing. For example, accessing an invalid index on an array. A fatal error is more unexpected. For example, imagine that the program knows that the index you're accessing on an array is valid, but the object has gone missing somehow. This would be unexpected and is a fatalError()
because there it's unlikely that your program can still reliably when objects randomly go missing from memory.
The last way of failing that I want to highlight is assertionFailure()
. An assertion failure is very similar to a precondition failure. The main difference is that an assertion failure is not always evaluated. When your app is running in debug mode and you call assertionFailure()
in your code, your app crashes. However, when you export your app for the app store, the assertion failures are stripped from your code and they won't crash your app. So assertion failures are a fantastic way for you to add loads of sanity checks to your codebase, and crash safely without impacting the user experience of your app in production.
All in all, we roughly have the following ways of communicating errors in Swift:
- Swift's
Result
type. - Throwing errors.
- Crashing with
preconditionFailure()
. - Crashing with
fatalError()
. - Crashing during development using
assertionFailure()
.
I would like to spend the next two sections on using the last three failure modes on the preceding list. Note that this list might be incomplete, or that some nuances could be missing. I've simply listed the errors that I see most commonly in code from others, and in my own code.
Crashing your app in production
When your app is on the App Store, there are a couple of things you should care about:
- Your app shouldn't crash.
- Your app should be responsive.
- You want to know about any problems your users run into.
It's not always easy to be able to hit all three targets. Especially if you avoid crashing at all costs, it might happen that instead of crashing your app shows a blank screen. The user will usually remedy this by force-closing your app and trying again, and if that works, it's a problem that you are not made aware of. So at that point, you miss two out of three marks; your app wasn't responsive, and you didn't know about the problem.
Sometimes, this is okay. Especially if it's unlikely to ever happen again. But if it does, you might not hear about it until the first bad reviews for your app start pouring in. One good way for you to be made aware of these kinds of problems is through logging. I won't go into any details for logging right now, but it's good practice to log any unexpected states you encounter to a server or other kind of remote log. By doing that, your app was unresponsive but you also have a log in your database.
This approach is great if you encounter a recoverable or rare error in production. It's not so great that your app may have been unresponsive, but at least you have captured the bad state and you can fix it in future releases. This is especially useful if the error or bug has a limited impact on your app's functionality.
There are also times when your app is more severely impacted and it makes little to no sense to continue operating your app in its current state. For example, if your app uses a login mechanism to prevent unauthorized access to certain parts of your app, it might make sense to use a preconditionFailure
if your app is about to show an unauthenticated user something they aren't supposed to see. A problem like this would indicate that something went wrong during development, and a programming error was made. Similar to how accessing an out of bounds index on Array
is considered a programming error. Let's look at a quick example of how preconditionFailure
can be used:
override func viewDidLoad() {
guard dataProvider.isUserLoggedIn == true else {
preconditionFailure("This page should never be loaded for an unauthenticated user")
}
}
Note that the return
keyword can be omitted in this guard
's else clause because there is no way for the program to continue after a preconditionFailure
.
Or maybe your app is supposed to include a configuration file that is read when your app launches. If this file is missing and your app has no way to determine a default or fallback configuration, it makes perfect sense to crash your app with a fatalError
. In that specific example, your app shouldn't even pass Apple's App Review because it doesn't work at all, and bundled files don't typically go missing from people's devices randomly. And when they do, it's probably because the app was tampered with and the user should expect things to crash. So when your app is live in the App Store, it's pretty safe to expect any files you bundled with the app to exist and be valid, and to crash if this is not the case.
The major difference between being unresponsive and allowing the user to manually kill your app and pro-actively crashing is the message it sends to the user. An unresponsive UI could mean anything, and while it's frustrating it's likely that the user will kill the app and relaunch it. They will expect whatever issue they ran into to be a rare occurrence and it probably won't happen again. While crashing is more serious. This tells your user that something is seriously wrong.
I can't tell you which of the two is the better approach. In my opinion, you need to find a balance between recovering from errors if possible, leaving your app in a potentially unexpected state and crashing. What's maybe most important here is that you make sure that you know about the issues users run into through logging unexpected states and crashing so you get crash reports.
Crashing your app during development
While you need to be careful about crashing in production, you can be a little bit more relentless during development. When your app is run in debug mode, fatal errors and preconditions work the same as they do in your App Store build. However, you have one more tool at your disposal during development; assertion failures.
Assertion failures are best used in places where your app might still work okay but is not in an expected state. These are the cases where you would want your production builds to send a log message to a server so you know that something was wrong. During development, however, you can call assertionFailure
and crash instead. Doing this will make sure that you and your teammates see this error, and you can decide whether it's something you can fix before shipping your app.
The nice thing about assertion failure is that they are only used during development, so you can use them quite liberally in your code to make sure that your code doesn't do anything unexpected, and that no mistakes go unnoticed through silent failures. Let's look at the same example I showed earlier for the unauthenticated access of a part of your app, except this time we're less restrictive.
override func viewDidLoad() {
guard dataProvider.isUserLoggedIn == true else {
assertionFailure("This page should never be loaded for an unauthenticated user")
dataLogger.log("Attempted to show view controller X to unauthenticated user.", level: .error)
return
}
}
In the above example, the view would still be shown to the user in production and an error message is logged to a data logger. We need to return
from the guard
's else clause here because an assertion failure does not guarantee that it will terminate your app.
In summary
There are many ways for developers to communicate errors in their apps. Some are very friendly, like for example a Result
type with an error state, or using throws
to throw errors. Others are more rigorous, like fatalError
, preconditionFailure
and assertionFailure
. These errors crash your app, sending a very strong message to you and your users.
In this article, I have explained several reasons for crashing your apps, and I've shown you that different ways of crashing your app make sense in different circumstances. The example that I used for preconditionFailure
and assertionFailure
is one that might seem farfetched. When I wrote them down I realized that the obvious fix for the problem was to not show a restricted view controller in the first place if a user isn't logged in. If you thought the same you'd be right. But the problem we're solving here is a different one. By performing precondition and assertion checks in this hypothetical case, we make sure that any mistakes your teammates might make are caught. You don't always know whether your app attempts to present a restricted view controller to a non-authenticated user until something in your app starts yelling at you. And that's what assertions and preconditions are both really good at. So I hope that with that in mind, the example makes sense to you.
If you have any questions, or feedback for me, please reach out on Twitter.
Top comments (0)