A few months ago, I stumbled upon a discussion in the thoughtbot guides about the use of fatal errors in Swift. It seems every developer has an opinion about fatal errors and a consensus hasn't been reached yet in the Swift community. The only mention in The Swift Programming Language is in the section that discusses the guard
statement and early exit.
When I fist encountered the fatalError(_:file:line:)
function, it reminded me of the abort()
function. Even though both functions cause the immediate termination of your application, the fatalError(_:file:line:)
function is different and, in the context of Swift, it is much more useful.
What to Do When You Don't Know What to Do
Ever since I started working with Swift, I have been struggling with the implementation of the tableView(_:cellForRowAt:)
method. If you think that sounds silly, then take a look at the following example.
override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
if let cell = tableView.dequeueReusableCell(withIdentifier: SettingsTableViewCell.reuseIdentifier, for: indexPath) as? SettingsTableViewCell {
// Configure Cell
cell.textLabel?.text = "Some Setting"
return cell
} else {
return UITableViewCell()
}
}
There are several variations of the above implementation and I have tried all of them. The idea is simple. We expect an instance of the SettingsTableViewCell
class if we ask the table view for a cell with the reuse identifier of the SettingsTableViewCell
class. Because the dequeueReusableCell(withIdentifier:for:)
method returns a UITableViewCell
instance, we need to cast the result to an instance of the SettingsTableViewCell
class.
This is inconvenient since we always expect to receive a SettingsTableViewCell
instance if we ask the table view for a cell with the reuse identifier of the SettingsTableViewCell
class. We could use as!
instead of as?
, but that is not a solution I feel comfortable with. I avoid the exclamation mark whenever I can.
If, for some reason, something goes wrong, we return a UITableViewCell
instance from the tableView(_:cellForRowAt:)
method. But that should never happen. Right?
While this is fine and necessary to make sure we return a UITableViewCell
instance from the tableView(_:cellForRowAt:)
method, I hope you can see that we are implementing a workaround for a scenario we do not expect, a scenario that should never occur.
Guard Against Unexpected Events
Every time I implement tableView(_:cellForRowAt:)
I wonder if there is a better approach. And there is. We need to guard against the event that the table view hands us a UITableViewCell
instance we don't expect and, if that happens, we throw a fatal error.
override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
guard let cell = tableView.dequeueReusableCell(withIdentifier: SettingsTableViewCell.reuseIdentifier, for: indexPath) as? SettingsTableViewCell else { fatalError("Unexpected Index Path") }
// Configure Cell
...
return cell
}
The application crashes if a fatal error is thrown. Why is this better? This is a better solution for two reasons.
Unexpected State
If the application runs into a scenario in which a fatal error is thrown, we communicate to the runtime that the application is in a state it does not know how to handle.
In the first example, the solution was to return a UITableViewCell
instance. Does that solve the problem? No. The application ignores the situation and avoids causing havoc by returning a UITableViewCell
instance. The application avoids dealing with the unexpected state it is in.
Finding and Fixing the Bug
If the application runs into a state we did not anticipate, it means a bug has slipped into the codebase. If the application is terminated due to a fatal error being thrown, we have work to do. It means we need to find the bug and fix it.
The above solution, using a guard
statement and throwing a fatal error, is a solution I am very happy with. It avoids the obsolete if
statement of the first implementation of the tableView(_:cellForRowAt:)
method and it correctly handles the situation. The result is a much more elegant implementation of the tableView(_:cellForRowAt:)
method. And it adds clarity to the implementation of the tableView(_:cellForRowAt:)
method.
Use Fatal Errors Sparingly
This doesn't mean that you need to use fatal errors whenever you want to avoid error handling or the application enters a state that is hard to recover from. I use fatal errors only when the application can enter in a state it was not designed for. Take a look at the following example.
import Foundation
enum Section: Int {
case news
case profile
case settings
var title: String {
switch self {
case .news: return NSLocalizedString("section_news", comment: "news")
case .profile: return NSLocalizedString("section_profile", comment: "profile")
case .settings: return NSLocalizedString("section_settings", comment: "settings")
}
}
}
struct SettingsViewViewModel {
func title(for section: Int) -> String {
guard let section = Section(rawValue: section) else { fatalError("Unexpected Section") }
return section.title
}
}
The title(for:)
method of the SettingsViewViewModel
struct does not expect a value it cannot use to instantiate a valid Section
instance with. If the value of the section
parameter is invalid, it would not know what to do. In that scenario, a fatal error is thrown.
If the application does enter that scenario, it means you made a logical mistake and it is your task to find out why and how it can be resolved.
Clarity and Elegance
The use of the fatalError(_:file:line:)
function has made my code more readable without compromising the inherent safety of the Swift language. It adds clarity to the code I write and Swift regains its elegance. Give it a try and let me know if you like it.