Enums have a wide range of applications and some of them are not obvious until you learn about them. In this episode of Writing Elegant Code with Enums, you learn how you can use enums to increase type safety and eliminate stringly typed code.

A Playground

Fire up Xcode and create a playground by choosing the Blank template from the iOS section.

Type Safety with Enums

Remove the contents of the playground and add an import statement for the Foundation framework.

import Foundation

Creating an Analytics Client

For this episode, we create a simple analytics client. The analytics client sends events to a third party analytics service, such as Google Analytics. Declare a struct with name AnalyticsClient. The AnalyticsClient struct defines one method, send(event:properties:). The send(event:properties:) method accepts the name of the event as its first argument and a dictionary of key-value pairs as its second argument.

import Foundation

struct AnalyticsClient {

    // MARK: - Public API
    
    func send(event: String, properties: [String: String]) {

    }

}

The AnalyticsClient struct declares a private struct with name Payload that conforms to the Encodable protocol. The Payload struct is used to convert the event and its properties to a Data object so that it can be sent to the third party analytics service.

The Payload struct defines two properties, event and properties. The event property is of type String. The properties property is a dictionary with keys of type String and values of type String.

import Foundation

struct AnalyticsClient {

    // MARK: - Types

    private struct Payload: Encodable {

        // MARK: - Properties

        let event: String
        let properties: [String: String]

    }

    // MARK: - Public API

    func send(event: String, properties: [String: String]) {

    }

}

In the body of the send(event:properties:) method, the analytics client creates a Payload object. It uses a JSONEncoder instance to convert the Payload object to a Data object. Because the encode(_:) method is throwing, we wrap the operation in a do-catch statement. We print the error in the catch clause.

func send(event: String, properties: [String: String]) {
    let payload = Payload(event: event, properties: properties)

    do {
        let data = try JSONEncoder().encode(payload)

        // Send Data to Analytics Service
    } catch {
        print("Unable to Encode Payload \(error)")
    }
}

While the implementation looks acceptable, we can make several improvements. Because event is of type String and the dictionary of properties has keys and values of type String, the codebase will be littered with string literals. That is something we need to avoid. The solution I have in mind takes advantage of enums and the result not only eliminates string literals it also adds type safety.

Avoiding String Literals

We start simple. We define an enum with name Event. The raw values of the Event enum are of type String. As the name suggests, each case of the Event enum defines an event. The raw value of each case is the name of the event. This has several benefits.

enum Event: String {
	
}

First, we no longer need to pass a string literal to the analytics client. We can pass it an Event object instead. Second, we can keep the codebase clean by choosing sensible names for the cases of the Event enum. What do I mean by that? Event names are often defined by a project manager or a data team and the names aren't always pretty or consistent. That is no longer a concern.

Let me show you an example. We define a case with name viewedProduct. That is the name we choose and the name we use in the codebase. The event name that is sent to the third party analytics service may have a different name, for example, view_procuct_test-2022. As a developer, you want your code to look pretty. Right? We don't need to use the event name you are given in the codebase. The Event enum hides it from the rest of the codebase.

enum Event: String {

    case viewedProduct = "view_procuct_test-2022"

}

Let's update the send(event:properties:) method of the AnalyticsClient struct by putting the Event enum to use. We pass an Event object as the first argument of the send(event:properties:) method. In the body of the send(event:properties:) method, we access the event name through the rawValue property.

func send(event: Event, properties: [String: String]) {
    let payload = Payload(event: event.rawValue, properties: properties)

    do {
        let data = try JSONEncoder().encode(payload)

        // Send Data to Analytics Service
    } catch {
        print("Unable to Encode Payload \(error)")
    }
}

Type Safety with Enums

Let's take it one step further. We define another enum with name Property. As you may have guessed, a Property object defines a property of an event. A property of an event has a value and we can use an associated value to define that value. Let me show you how that works.

We declare a case with name product. The product case has an associated value with label id and type String. We define another case, price, with an associated value of type Float. Notice that the associated value of the price case doesn't have a label. I tend to only define a label for an associated value if it adds meaning. In this example, the name of the case, price, describes the associated value so a label isn't strictly necessary. That is personal choice, though.

enum Property {

    // MARK: - Cases

    case product(id: String)
    case price(Float)

}

The Property enum declares two computed properties, key of type String and value of type String. If an enum has one or more cases that define one or more associated values, then it cannot define a raw type. That is why we declare the computed key property. The implementation of the computed key property is easy enough.

enum Property {

    // MARK: - Cases

    case product(id: String)
    case price(Float)

    // MARK: - Properties

    var key: String {
        switch self {
        case .product:
            return "product"
        case .price:
            return "price"
        }
    }

}

The implementation of the computed value property isn't complex either. Each case returns its associated value. The associated value of the price case is converted to a string to meet the requirement of the computed value property. The possibilities are endless, though. You are free to transform the associated value however you like as long as the returned value is of type String.

enum Property {

    // MARK: - Cases

    case product(id: String)
    case price(Float)

    // MARK: - Properties

    var key: String {
        switch self {
        case .product:
            return "product"
        case .price:
            return "price"
        }
    }

    var value: String {
        switch self {
        case .product(id: let id):
            return id
        case .price(let price):
            return "\(price)"
        }
    }

}

Revisit the send(event:properties:) method of the AnalyticsClient struct. We make two changes. The type of the properties parameter is Property and we turn the properties parameter into a variadic parameter.

func send(event: Event, properties: Property...) {
	...
}

The initializer of the Payload struct expects a dictionary of properties so we need to transform the sequence of Property objects to a dictionary. We do that by invoking the reduce(into:_:) method on the sequence of Property objects. The reduce(into:_:) method defines two parameters, the initial result and a closure that is invoked for each element of the sequence.

Because we want to convert the sequence of properties to a dictionary, the initial result is a dictionary with keys of type String and values of type String. The second argument of the reduce(into:_:) method is a closure that accepts the partial result and an element of the sequence. In the closure, we update the partial result. We pass the result of the reduce(into:_:) method, the dictionary of properties, to the initializer of the Payload struct.

func send(event: Event, properties: Property...) {
    let eventProperties = properties.reduce(into: [String: String]()) { partialResult, property in
        partialResult[property.key] = property.value
    }

    let payload = Payload(event: event.rawValue, properties: eventProperties)

    do {
        let data = try JSONEncoder().encode(payload)

        // Send Data to Analytics Service
    } catch {
        print("Unable to Encode Payload \(error)")
    }
}

Testing the Implementation

Let's put the AnalyticsClient struct to the test. We create an analytics client and invoke its send(event:properties:) method. The first argument is an Event object. We don't need to fiddle with string literals, eliminating the risk for typos. The second argument is a sequence of properties. Because the properties parameter is variadic, the API feels elegant and intuitive.

AnalyticsClient().send(
    event: .viewedProduct,
    properties: .product(id: "my.product"), .price(15.25)
)

This is an example and that is why the associated values are object literals. In a project, those object literals wouldn't be necessary.

What's Next?

Enums with associated values are convenient and powerful. In this episode, you learned how to use enums to eliminate stringly typed code. It is important to understand that the associated value of each case defines an API contract between the analytics client and the consumer of the API, the developer. It ensures that the product ID is a string and the price of the product is a float. That is a powerful concept that eliminates common bugs.