Enums in Swift — A Deep Dive

Tomás Mamede
5 min readSep 23, 2024

--

Enums (Enumerations) are one of the most powerful features of the Swift Programming Language. They are a fist-class type, which means they support all the operations available to other types in Swift. Also, they can conform to protocols, support associated values — allowing for dynamic data storage — they can be used in collections (arrays, sets, dictionaries, …), be passed as arguments and returned from functions.

Swift Programming Language logo

In fact, Enums enhance the robustness, safety, readability and expressiveness of the code. They combine simplicity with complexity because they can be mere labels or complex types that can encapsulate behaviour and state, which make them a great tool for modelling data in applications.

Defining Cases

The simplest definition of an Enum is to define a type that has a fixed set of values, known as cases. By using Enums we make sure that only values of that type can be used, helping to prevent errors and catch them at compile time.

enum Festivities {

case christmas
case newYear
case easter
}

var festivity = Festivities.christmas

Enums work effortlessly with Swift’s pattern matching in switch statements, ensuring that all cases are covered or ignored with a default case.

switch festivity {
case .christmas:
print("Merry Christmas")
case .newYear:
print("Happy New Year")
case .easter:
print("Happy Easter")
}

Raw Values

With Enums we can use RawValues to associate a default value to each one of the defined cases. Default values have to conform to the RawRepresentable protocol, which includes all the basis Swift types.

enum HTTPStatusCode: Int {
case ok = 200
case badRequest = 400
case notFound = 404
case internalServerError = 500
}

RawValues can be used for converting Enums to and from other representations, such as, JSON or when dealing with database storage. This makes Enums very versatile, helping bridge the gap between Swift’s strong type system and other data representations.

When initialising from a RawValue we always get an optional value since raw values don't always map to a unique case.

if let ok = HTTPStatusCode(rawValue: 200) {

print(ok)
}

Associated Values

Associated values give a lot of flexibility to Enums, making them more than just a list of named constants. Associated values encapsulate additional data with each case enabling more complex use cases of this feature.

Associated values can be values of any type, which means that each case carries its own custom data. To extract these values we need to use switch statements. Like with struct and classes, Enums support methods and computed properties that can operated on the Enum itself, providing more complex behaviour, logic and transformations based on the state of the Enum.

enum Animal {

case dog(age: Int, name: String, isAGoodBoy: Bool)
case cat(age: Int, name: String)

func description() -> String {

switch self {
case .dog(let age, let name, let isAGoodBoy):
return "\(name) is \(age) years old and he is \(isAGoodBoy ? "": "not") a good boy."
case .cat(let age, let name):
return "\(name) is \(age) years old."
}
}
}

let dogOne: Animal = .dog(age: 3, name: "Mac", isAGoodBoy: true)
dogOne.description()

Associated values make the code more self-explanatory by combining state and data in one structure, while ensuring that the data is always of the expected type. This enhances code safety.

Conformance to Protocols

Protocols define a blueprint of methods or properties that must be implemented by any type conforming to them. Enums can also conform to protocols, behaving more like classes or structs. This also enables polymorphism, where different objects can be treated as if they were of the same type.

protocol Vehicle {

func numberOfWheels() -> Int
}

enum Car: Vehicle {

case suv
case sedan
case wagon
case miniVan

func numberOfWheels() -> Int {
return 4
}
}

enum Motorcycle: Vehicle {

case sport
case cruiser
case touring

func numberOfWheels() -> Int {
return 2
}
}

This feature allows for more flexible, reusable and abstract code.

Generics

Generics allow code that works with any data type. By combining this feature with Enums it is possible to define Enums that can store associated values and execute other operations over values of any type.

Enums can be defined with one or more generic types. These types are passed when the object is created. In the end Generics enable more flexible and maintainable code.

enum Result<T, U> {

case success(T)
case loading(U)
case error(Error)
}

struct User {

let name: String
let age: Int
}

var result: Result<User, Bool> = .loading(true)
result = .success(.init(name: "Tomás Mamede", age: 25))

switch result {
case .success(let user):
print("Hello, \(user.name)")
case .loading(let hasCachedResponse) where hasCachedResponse == true:
print("Loading...")
case .loading(let hasCachedResponse) where hasCachedResponse == false:
print("Loading... This may take a while")
case .error(let error):
print(error.localizedDescription)
default:
print("Unexpected result.")
}

Furthermore, we can place constraints on the generic parameters of an Enum. This ensures that only certain types can be used with the Enum. For example we may want to ensure that the a generic type conforms to the Codable protocol.

enum Data<T: Codable> {
case driver(T)
case constructor(T)
}

Recursive Enums

A recursive Enum is an Enum where one or more case contains an associated value that refers back to the Enum itself. In Swift we use the keyword indirect to declare an Enum as recursive. Essentially, we are telling the compiler to use a pointer for each case, allowing it to reference other instances of the Enum safely.

To illustrate the power of recursive Enums we are going to use them to model a Binary Tree, which is an hierarchical data structure in which each node has at most two children. A Binary Tree can store values of any type.

indirect enum BinaryTree<T> {

case empty
case node(T, BinaryTree<T>, BinaryTree<T>)

func traverse() {

switch self {
case .empty:
return
case .node(let value, let leftChild, let rightChild):
print(value)
leftChild.traverse()
rightChild.traverse()
}
}
}

The empty case represents an empty node and acts as a base case. On the other hand the node case represents a non-empty node in the tree that holds a value of a certain type, a left child and a right child.

        5
/ \
2 6
/ \
4 4

To create this structure we must define each node.

let leafNode: BinaryTree<Int> = .node(4, .empty, .empty)
let leftChild: BinaryTree<Int> = .node(2, leafNode, .empty)
let rightChild: BinaryTree<Int> = .node(6, .empty, leafNode)
let rootNode: BinaryTree<Int> = .node(5, leftChild, rightChild)

The output of calling rootNode.traverse() is:

5
2
4
6
4

In this article we went through several use cases for Enums and their potential to solve different problems.

--

--

No responses yet