A magic number is a number that is used in code without much meaning due to the lack of context. Magic numbers are considered an anti-pattern because they make code harder to read, understand, and maintain. Refactoring code that uses magic numbers is also more risky and can be time-consuming.
String literals can introduce similar problems. Why is that? A string literal can result in stringly typed code. If a string is used instead of a more appropriate type, then we speak of stringly typed code.
I tend to avoid string literals as much as possible, especially if that string literal is used in several places. The good news is that you can avoid string literals with little effort. In this episode of Code Smells in Swift, I show you a few solutions to avoid string literals and stringly typed code.
String Literals
Let's take a look at a few examples to understand the problem. This is a common use case of string literals. To create a UIImage
instance from an asset in the asset catalog, we pass the name of the asset to one of the initializers of the UIImage
class. Because the initializer accepts a string, this seems like a valid use case for string literals. Right?
func imageForIcon(withName name: String) -> UIImage? {
switch name {
case "clear-day":
return UIImage(named: "clear-day")
case "clear-night":
return UIImage(named: "clear-night")
case "rain":
return UIImage(named: "rain")
case "snow":
return UIImage(named: "snow")
case "sleet":
return UIImage(named: "sleet")
case "wind",
"cloudy",
"partly-cloudy-day",
"partly-cloudy-night":
return UIImage(named: "cloudy")
default:
return UIImage(named: "clear-day")
}
}
What happens if you make a typo? The compiler won't complain because it doesn't check that the name you pass to the initializer matches an asset in the asset catalog. If you make a typo, the initialization fails silently and the application most likely renders an empty image view. Bugs like this can live in an application for quite some time before they show up on your radar.
Let's take a look at another example. The identifier of a storyboard segue is a string. You define the identifier of the segue in the storyboard and reference it in code. This seems like another valid use case for string literals. The repercussions of a typo can be more dramatic in this scenario.
override func prepare(for segue: UIStoryboardSegue, sender: Any?) {
guard let identifier = segue.identifier else { return }
switch identifier {
case "SegueDayView":
...
case "SegueWeekView":
...
case "SegueSettingsView":
...
case "SegueLocationsView":
...
default: break
}
}
The good news is that we can avoid string literals in both examples with a simple solution. Let's start with the segue identifiers. We define a private enum with name Segue
. For each segue, we declare a static constant with a descriptive name. The value of the static constant is the segue identifier.
private enum Segue {
static let dayView = "SegueDayView"
static let weekView = "SegueWeekView"
static let settingsView = "SegueSettingsView"
static let locationsView = "SegueLocationsView"
}
We can use the Segue
enum in the prepare(for:sender:)
method, eliminating the need for string literals.
override func prepare(for segue: UIStoryboardSegue, sender: Any?) {
guard let identifier = segue.identifier else { return }
switch identifier {
case Segue.dayView:
...
case Segue.weekView:
...
case Segue.settingsView:
...
case Segue.locationsView:
...
default: break
}
}
You may argue that we moved the string literals from the prepare(for:sender:)
method to the Segue
enum. That is true, but we no longer need to worry about typos in the prepare(for:sender:)
method. The string literals are defined in a single location, the compiler ensures we don't introduce typos, and we get auto-completion for free. We can take it one step further, though.
Let's revisit the Segue
enum. We define the raw values of the Segue
enum to be of type String
. We no longer use static constants. Each segue is defined by a case. The raw value of the case is the identifier of the segue.
private enum Segue: String {
case day = "SegueDayView"
case week = "SegueWeekView"
case settings = "SegueSettingsView"
case locations = "SegueLocationsView"
}
In the prepare(for:sender:)
method, we use the identifier of the segue to create a Segue
object. The initialization of the Segue
object fails if the identifier of the segue shouldn't be handled by the prepare(for:sender:)
method.
The added benefit of this solution is that we can switch on the Segue
object. The switch
statement no longer needs a default
case and the compiler throws an error if we forget to handle a segue we should handle.
override func prepare(for segue: UIStoryboardSegue, sender: Any?) {
guard
let identifier = segue.identifier,
let storyboardSegue = Segue(rawValue: identifier)
else {
return
}
switch storyboardSegue {
case .day:
...
case .week:
...
case .settings:
...
case .locations:
...
}
}
Code Generation
We can use a similar solution for creating a UIImage
instance from an asset in the asset catalog. A more scalable solution is to use a solution like SwiftGen. SwiftGen inspect the project's resources and generates the required code every time the project is built.
This has a number of benefits. Let me illustrate this with an example. I won't cover setting up SwiftGen in this episode. I already installed and configured SwiftGen for this project. We can leverage the code generated by SwiftGen to update the imageForIcon(withName:)
method. SwiftGen generated an enum with name Assets
. The Assets
enum defines a case for each asset in the asset catalog. We create a UIImage
instance through the computed image
property. That's it.
func imageForIcon(withName name: String) -> UIImage? {
switch name {
case "clear-day":
return Assets.clearDay.image
case "clear-night":
return Assets.clearNight.image
case "rain":
return Assets.rain.image
case "snow":
return Assets.snow.image
case "sleet":
return Assets.sleet.image
case "wind",
"cloudy",
"partly-cloudy-day",
"partly-cloudy-night":
return Assets.cloudy.image
default:
return Assets.clearDay.image
}
}
What happens if we remove an asset from the asset catalog? Because the generated code is updated every time the project is built, the compiler throws an error. We are notified that the project uses an asset that is no longer present in the asset catalog. This is useful for detecting missing assets, but it is also useful to clean up resources without running into surprises.
SwiftGen supports a variety of resources, including asset catalogs and localizable strings. You no longer need to worry about typos and auto-completion makes it quick and easy. You can drastically reduce your use of string literals with a tool like SwiftGen.
What's Next?
I consider random string literals a code smell just like magic numbers are a code smell. It takes little effort to avoid string literals and the solutions I shared in this episode are simple and have several benefits. The next time you are about to use a string literal, consider if there are other options.