Articles, podcasts and news about Swift development, by John Sundell.

Mixing enums with other Swift types

Published on 19 Apr 2020
Basics article available: Enums

One 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! 🚀