Using structs like enums in Swift

Jan 30, 2019 · Follow on Twitter and Mastodon swift

Swift enum and struct are two powerful tools. In this post, I’ll discuss how you typically use them, and how you can use structs like enums when you need more flexibility.

Enums

Swift Docs describes enums like this:

“An enumeration defines a common type for a group of related values and enables you to work with those values in a type-safe way within your code.”

In short, if you have a fixed set of optional, related values, enums are great. Swift enums are very powerful and support associated values, extensions, nesting etc.

For instance, the following enum specifies food options on a menu:

enum Food {
    case 
    hamburger, 
    cheeseBurger, 
    veggieBurger,
    chickenNuggets(pcs: Int)
}

I will not go into further detail on enums. If you want to learn more, see the official docs.

Structs

Swift Docs describes structs like this:

“Structs are general-purpose, flexible constructs that become the building blocks of your program’s code. You define properties and methods to add functionality to your structures and classes using the same syntax you use to define constants, variables, and functions.”

In short, structs are great value types that give you better data integrity than you get with classes. It took me a while to start using structs, but now I can’t live without them.

For instance, this struct defines a food order, with an immutable order number and items:

struct FoodOrder: Codable {

    let orderNumber: Int
    let items: [Food]
}

I will not go into further detail on structs. If you want to learn more, see the official docs.

Using structs instead of enums

Given the information above, consider that we want to specify a list of shadow styles, as well as an extension that can be used to apply a shadow style to a UIKit view.

If you’d asked me a few years ago, I would have implemented this using an enum:

enum ShadowStyle {
    
    case
    small,
    medium,
    large
}

We can then attach properties to this enum:

extension ShadowStyle {
    
    var alpha: Float {
        switch self {
        case .small: 0.2
        case .medium: 0.26
        case .large: 0.6
        }
    }
    
    var blur: CGFloat {
        switch self {
        case .small: 8
        case .medium: 15
        case .large: 40
        }
    }
    
    var color: UIColor { .black }
    
    var spread: CGFloat { 0 }
    
    var x: CGFloat { 0 }
    
    var y: CGFloat {
        switch self {
        case .small: 4
        case .medium: 5
        case .large: 10
        }
    }
}

I would then have created a UIView extension that can be used to apply a shadow style:

extension UIView {
    
    func applyShadow(_ shadow: ShadowStyle) {
        layer.applyShadow(
            color: shadow.color,
            alpha: shadow.alpha,
            x: shadow.x,
            y: shadow.y,
            blur: shadow.blur,
            spread: shadow.spread
        )
    }
}

private extension CALayer {
    
    func applyShadow(
        color: UIColor = .black,
        alpha: Float = 0.5,
        x: CGFloat = 0,
        y: CGFloat = 2,
        blur: CGFloat = 4,
        spread: CGFloat = 0
    ) {
        shadowColor = color.cgColor
        shadowOpacity = alpha
        shadowOffset = CGSize(width: x, height: y)
        shadowRadius = blur / 2.0
        shouldRasterize = true
        rasterizationScale = UIScreen.main.scale
        if spread == 0 {
            shadowPath = nil
        } else {
            let dx = -spread
            let rect = bounds.insetBy(dx: dx, dy: dx)
            shadowPath = UIBezierPath(rect: rect).cgPath
        }
    }
}

We can now use the enum to apply shadows like this:

let view = UIView(frame.zero)
view.applyShadow(.medium)

This works great, but the enum approach has a big limitation. If the shadow is defined in a library, e.g. in an open source library, we’d be stuck with the fixed values that it provides.

Sometimes, this is EXACTLY what you want - a fixed set of options. If so, use enums. However, if you find that the enum model is holding you back, you can use structs instead.

For instance, this is how a ShadowStyle struct could look:

struct ShadowStyle {
    
    init(
        alpha: Float,
        blur: CGFloat,
        color: UIColor = .black,
        spread: CGFloat = 0,
        x: CGFloat,
        y: CGFloat
    ) {
        self.alpha = alpha
        self.blur = blur
        self.color = color
        self.spread = spread
        self.x = x
        self.y = y
    }
    
    let alpha: Float
    let blur: CGFloat
    let color: UIColor
    let spread: CGFloat
    let x: CGFloat
    let y: CGFloat
}

This struct has the same properties as the enum, but is easier to overview. We define the properties when we create a shadow, after which they’re immutable.

We can now add as many shadow styles as we want, in a way that I think is more natural:

extension Shadow {

    static var small: Shadow {
        .init(alpha: 0.2, blur: 8, x: 0, y: 4)
    }
    
    static var medium: Shadow {
        .init(alpha: 0.26, blur: 15, x: 0, y: 5)
    }
    
    static var large: Shadow {
        .init(alpha: 0.6, blur: 40, x: 0, y: 10)
    }
}

We now have a set of shadow styles that we can apply just like with the enum from before:

let view = UIView(frame.zero)
view.applyShadow(.medium)

This is much more flexible. No enum is holding us back. However, there are some side-effects with structs that you must be aware of.

Unexpected side-effects with using structs

You can handle structs in much the same way as an enum, e.g. compare two Equatable instances, switch over values etc.

For enums, it would look like this:

public enum Direction: Equatable {
    case up, down, left, right
}

let environment = Direction.down

if environment == .up { print("Up") }       // Won't happen
if environment == .down { print("Down") }   // Will happen

switch environment {
case .up: print("Up")                       // Won't happen
default: print("Not up")                    // Will happen
}

For structs, the equality check and switching looks identical:

struct Direction: Equatable {}

extension Direction {
    static var up: Direction { .init() }
    static var down: Direction { .init() }
    static var left: Direction { .init() }
    static var right: Direction { .init() }
}

let direction = Direction.down

if direction == .up { print("Up") }         // Will happen
if direction == .down { print("Down") }     // Will happen

switch direction {
case .up: print("Up")                       // Will happen
default: print("Not up")                    // Won't happen
}

…but the results does not. We create a .down direction, but when we check for equality and switch over it, it’s equal to both .up and .down. What’s going on?

The reason is that all directions are identical! The Direction struct is implicitly equatable, but uses identical values for all four “cases”.

To solve this, every struct options must be unique. We can solve this by making sure that Equatable behaves correctly, for instance:

struct Direction: Equatable {
    let name: String
}

extension Direction {
    static var up: Direction { .init(name: "up") }
    static var down: Direction { .init(name: "down") }
    static var left: Direction { .init(name: "left") }
    static var right: Direction { .init(name: "right") }
}

let direction = Direction.down

if direction == .up { print("Up") }         // Won't happen
if direction == .down { print("Down") }     // Will happen

switch direction {
case .up: print("Up")                       // Won't happen
default: print("Not up")                    // Will happen
}

By making each direction unique, this now works! We don’t have to implement any equality logic, just provide the struct with a property that is unique for each case, and we’re done.

Conclusion

Enums are great when you want a fixed set of options. However, a struct that behaves like an enum is better when you want a type that can be extended with more options.

Another option is to used parameterized enum cases, which you can use to define various predefined configurations. You’d still however be constrained to the defined cases.

When this post was written, Swift enums couldn’t implement certain protocols if a case had parameters. This is however no longer the case, so this will not hold you back.

In the end, use the approach you feel is most fitting for your use-case. Just watch out for the pitfalls that exist with either approach.