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.
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.