Mixing enums with other Swift types
Basics article available: EnumsOne really interesting aspect of Swift is just how many different language features that it supports. While it could definitely be argued that having lots of features at our disposal perhaps makes the language more complex than it needs to be, it’s also a big part of what makes Swift so flexible when it comes to how we write and structure our code.
While using all of Swift’s language features as much as possible is hardly a good goal to have, building a truly great Swift program often comes down to making the best use of each feature that’s relevant to what we’re looking to build — which often means mixing them in order to best take advantage of what each feature has to offer.
This week, let’s take a look at a few examples of doing just that — specifically when it comes to how enums can be mixed with some of Swift’s other features in order to improve the predictability of our logic, while also reducing boilerplate.
Eliminating multiple sources of truth
One of the most common problems within software engineering in general is logic that relies on multiple sources of truth for a given piece of data — especially when those sources might end up contradicting each other, which tends to result in undefined states.
For example, let’s say that we’re working on an app for writing articles, and that we’d like to use the same data model to represent articles that have been published, as well as unpublished drafts.
To handle those two cases, we might give our data model an isDraft
property that indicates whether it’s representing a draft, and we’d also need to turn any data that’s unique to published articles into optionals — like this:
struct Article {
var title: String
var body: Content
var url: URL? // Only assigned to published articles
var isDraft: Bool // Indicates whether this is a draft
...
}
At first, it might not seem like the above model has multiple sources of truth — but it actually does, since whether an article should be considered published could both be determined by looking at whether it has a url
assigned to it, or whether its isDraft
property is true
.
That may not seem like a big deal, but it could quite quickly lead to inconsistencies across our code base, and it also requires unnecessary boilerplate — as each call site has to both check the isDraft
flag, and unwrap the optional url
property, in order to make sure that its logic is correct.
This is exactly the type of situation in which Swift’s enums really shine — since they let us model the above kind of variants as explicit states, each of which can carry its own set of data in a non-optional manner — like this:
extension Article {
enum State {
case published(URL)
case draft
}
}
What the above enum enables us to do is to replace our previous url
and isDraft
properties with a new state
property — which will act as a single source of truth for determining the state of each article:
struct Article {
var title: String
var body: Content
var state: State
}
With the above in place we can now simply switch
on our new state
property whenever we need to check whether an article has been published — and the code paths for published articles no longer need to deal with any optional URLs. For example, here’s how we could now conditionally create a UIActivityViewController
for sharing published articles:
func makeActivityViewController(
for article: Article
) -> UIActivityViewController? {
switch article.state {
case .published(let url):
return UIActivityViewController(
activityItems: [url],
applicationActivities: nil
)
case .draft:
return nil
}
}
However, when making the above kind of structural change to one of our core data models, chances are that we’ll also need to update quite a lot of code that uses that model — and we might not be able to perform all of those updates at once.
Thankfully, it’s often relatively easy to solve that type of problem through some form of temporary backward compatibility layer — which uses our new single source of truth under the hood, while still exposing the same API as we had before to the rest of our code base.
For example, here’s how we could let Article
temporarily keep its url
property until we’re done migrating all of our code to its new state
API:
#warning("Temporary backward compatibility. Remove ASAP.")
extension Article {
@available(*, deprecated, message: "Use state instead")
var url: URL? {
get {
switch state {
case .draft:
return nil
case .published(let url):
return url
}
}
set {
state = newValue.map(State.published) ?? .draft
}
}
}
Above we’re using both the #warning
compiler directive, and the @available
attribute, to have the compiler emit warnings both wherever our url
property is still used, and to remind us that this extension should be removed as soon as possible.
So that’s an example of how we can mix structs and other types with enums in order to establish a single source of truth for our various states. Next, let’s take a look at how we can go the other way around, and augment some of our enums to make them much more powerful — while also reducing our overall number of switch
statements in the process.
Enums versus protocols
Following the above idea of using enums to model distinct states — let’s now say that we’re working on a drawing app, and that we’ve currently implemented our tool selection code using an enum that contains all of the drawing tools that our app supports:
enum Tool: CaseIterable {
case pen
case brush
case fill
case text
...
}
Besides the state management aspects, one additional benefit of using an enum in this case is the CaseIterable
protocol, which our Tool
type conforms to. Like we took a look at in “Enum iterations in Swift”, conforming to that protocol makes the compiler automatically generate a static allCases
property, which we can then use to easily iterate through all of our cases — for example in order to build a toolbox view that contains buttons for each of our drawing tools:
func makeToolboxView() -> UIView {
let toolbox = UIView()
for tool in Tool.allCases {
// Add a button for selecting the tool
...
}
return toolbox
}
However, as neat as it is to have all of our tools gathered within a single type, that setup does come with a quite major disadvantage in this case.
Since all of our tools are likely going to need a fair amount of logic, and using an enum requires us to implement all of that logic within a single place, we’ll probably end up with series of increasingly complex switch
statements — looking something like this:
extension Tool {
var icon: Icon {
switch self {
case .pen:
...
case .brush:
...
case .fill:
...
case .text:
...
...
}
}
var name: String {
switch self {
...
}
}
func apply(at point: CGPoint, on canvas: Canvas) {
switch self {
...
}
}
}
Another issue with our current approach is that it makes it quite difficult to store tool-specific states — since enums that conform to CaseIterable
can’t carry any associated values.
To address both of the above two problems, let’s instead try to implement each of our tools using a protocol — which would give us a shared interface, while still enabling each tool to be declared and implemented in isolation:
// A protocol that acts as a shared interface for each of our tools:
protocol Tool {
var icon: Icon { get }
var name: String { get }
func apply(at point: CGPoint, on canvas: Canvas)
}
// Simpler tools can just implement the required properties, as well
// as the 'apply' method for performing their drawing:
struct PenTool: Tool {
let icon = Icon.pen
let name = "Draw using a pen"
func apply(at point: CGPoint, on canvas: Canvas) {
...
}
}
// More complex tools are now free to declare their own state properties,
// which could then be used within their drawing code:
struct TextTool: Tool {
let icon = Icon.letter
let name = "Add text"
var font = UIFont.systemFont(ofSize: UIFont.systemFontSize)
var characterSpacing: CGFloat = 0
func apply(at point: CGPoint, on canvas: Canvas) {
...
}
}
However, while the above change enables us to fully decouple our various Tool
implementations, we’ve also lost one of the major benefits of our enum-based approach — that we could easily iterate over each tool by using Tool.allCases
.
While we could sort of achieve the same thing using a manually implemented function (or use some form of code generation), that’s extra code that we’d have to maintain and keep in sync with our various Tool
types — which isn’t ideal:
func allTools() -> [Tool] {
return [
PenTool(),
BrushTool(),
FillTool(),
TextTool()
...
]
}
But what if we didn’t have to make a choice between protocols and enums, and instead could mix them to sort of achieve the best of both worlds?
Enum on the outside, protocol on the inside
Let’s revert our Tool
type back to being an enum, but rather than again implementing all of our logic as methods and properties full of switch
statements — let’s instead keep those implementations protocol-oriented, only this time we’ll make them controllers for our tools, rather than being model representations of the tools themselves.
Using our previous Tool
protocol as a starting point, let’s define a new protocol called ToolController
, which — along with our previous requirements — includes a method that lets each tool provide and manage its own options view. That way, we can end up with a truly decoupled architecture, in which each controller completely manages the logic and UI required for each given tool:
protocol ToolController {
var icon: Icon { get }
var name: String { get }
func apply(at point: CGPoint, on canvas: Canvas)
func makeOptionsView() -> UIView?
}
Going back to our TextTool
implementation from before, here’s how we could modify it to instead become a TextToolController
that conforms to our new protocol:
class TextToolController: ToolController {
let icon = Icon.letter
let name = "Add text"
private var font = UIFont.systemFont(ofSize: UIFont.systemFontSize)
private var characterSpacing: CGFloat = 0
func apply(at point: CGPoint, on canvas: Canvas) {
...
}
func makeOptionsView() -> UIView? {
let view = UIView()
let characterSpacingStepper = UIStepper()
view.addSubview(characterSpacingStepper)
// When creating our tool-specific options view, our
// controller can now reference its own instance methods
// and properties, just like a view controller would:
characterSpacingStepper.addTarget(self,
action: #selector(handleCharacterSpacingStepper),
for: .valueChanged
)
...
return view
}
...
}
Then, rather than having our Tool
enum contain any actual logic, we’ll just give it a single method for creating a ToolController
corresponding to its current state — saving us the trouble of having to write all those switch
statements that we had before, while still enabling us to make full use of CaseIterable
:
enum Tool: CaseIterable {
case pen
case brush
case fill
case text
...
}
extension Tool {
func makeController() -> ToolController {
switch self {
case .pen:
return PenToolController()
case .brush:
return BrushToolController()
case .fill:
return FillToolController()
case .text:
return TextToolController()
...
}
}
}
An alternative to the above approach would be to create a dedicated ToolControllerFactory
, rather than having Tool
itself create our controllers. To learn more about that pattern, check out this page.
Finally, putting all of the pieces together, we’ll now be able to both easily iterate over each tool in order to build our toolbox view, and trigger the current tool’s logic by communicating with its ToolController
— like this:
class CanvasViewController: UIViewController {
private var tool = Tool.pen {
didSet { controller = tool.makeController() }
}
private lazy var controller = tool.makeController()
private let canvas = Canvas()
...
private func makeToolboxView() -> UIView {
let toolbox = UIView()
for tool in Tool.allCases {
// Add a button for selecting the tool
...
}
return toolbox
}
private func handleTapRecognizer(_ recognizer: UITapGestureRecognizer) {
// Handling taps on the canvas using the current tool's controller:
let location = recognizer.location(in: view)
controller.apply(at: location, on: canvas)
}
...
}
The beauty of the above approach is that it enables us to fully decouple our logic, while still establishing a single source of truth for all of our states and variants. We could’ve also chosen to split our code up a bit differently, for example to keep each tool’s icon
and name
within our enum, and only move our actual logic out to our ToolController
implementations — but that’s always something we could tweak going forward.
Conclusion
While it sometimes might seem like we always need to pick just a single type of abstraction within each given situation, we can often achieve some really interesting results when combining and mixing several of Swift’s language features into a single solution — for example by combining the predictability of enums with the flexibility of protocols.
What do you think? Have you ever mixed enums with Swift’s other features and types in order to solve a specific problem? Let me know — along with your questions, feedback and comments — either via email or on Twitter.
Thanks for reading! 🚀