We still use string literals to define the properties of an event. That is something I would like to change in this episode. The Journey enum defines the list of events the API supports. We take a similar approach for the properties of an event.

Defining a Property

The property of an event has a name and a value. That means that the type we define to encapsulate a property needs to expose the name and the value of the property in some way. Open Journey.swift and declare an enum with name Property in the extension in which we declared the Event struct.

extension Journey {

    struct Event {

        // MARK: - Properties

        let name: String
        let properties: [String: Any]

        // MARK: - Public API

        func send(to analyticsService: GoogleAnalyticsClient = .shared) {
            analyticsService.trackEvent(with: name, properties: properties)
        }

    }

    enum Property {

    }

}

Each property of an event corresponds to a case of the Property enum. Revisit NotesViewModel.swift. The notes view model defines three properties, source, kind, and word_count. The source property defines where in the application the user created the note, the kind property defines the type or kind of note the user created, and the value of the word_count property stores the word count of the note. Let's create a case for each of these properties. Open Journey.swift and create three cases, kind, source, and wordCount.

extension Journey {

    struct Event {

		...

    }

    enum Property {

        // MARK: - Cases

        case kind
        case source
        case wordCount

    }

}

An enum cannot have stored properties. How can a Property object expose the value of the property it defines? We could create a struct that encapsulates the name and value of a property, similar to the Event struct. There is a better option, though. We can elegantly resolve this problem by leveraging associated values. Each case of the Property enum defines an associated value. The associated value is the value of the property. The type of the associated value matches the type of the property's value. The associated value of the kind and source cases is String and the associated value of the wordCount case is Int.

extension Journey {

    struct Event {

        ...

    }

    enum Property {

        // MARK: - Cases

        case kind(String)
        case source(String)
        case wordCount(Int)

    }

}

Updating the Event Struct

With the Property enum in place, we can update the Event struct. We change the type of the properties property to [Property].

extension Journey {

    struct Event {

        // MARK: - Properties

        let name: String
        let properties: [Property]

        // MARK: - Public API

        func send(to analyticsService: GoogleAnalyticsClient = .shared) {
            analyticsService.trackEvent(with: name, properties: properties)
        }

    }

    enum Property {

        // MARK: - Cases

        case kind(String)
        case source(String)
        case wordCount(Int)

    }

}

That change has a few consequences. We need to update the send(to:) method of the Event struct and the properties(_:) method of the Journey enum. Before we update the send(to:) method, we add two computed properties to the Property enum, name of type String and value of type Any.

The computed name property defines the mapping between the cases of the Property enum and the property names that are sent to the analytics service. Its purpose is identical to that of the computed event property of the Journey enum.

enum Property {

    // MARK: - Cases

    case kind(String)
    case source(String)
    case wordCount(Int)

    // MARK: - Properties

    var name: String {
        switch self {
        case .kind: return "kind"
        case .source: return "source"
        case .wordCount: return "word_count"
        }
    }

}

The computed value property returns the associated value of the Property object. The associated value of a case can be of any type, but it is the computed value property that defines the type of the value of the property that is sent to the analytics service. Using associated values in this way is a powerful concept that guarantees type safety. Don't worry if this doesn't make sense at this point.

enum Property {

    // MARK: - Cases

    case kind(String)
    case source(String)
    case wordCount(Int)

    // MARK: - Properties

    var name: String {
        switch self {
        case .kind: return "kind"
        case .source: return "source"
        case .wordCount: return "word_count"
        }
    }

    var value: Any {
        switch self {
        case .kind(let kind): return kind
        case .source(let source): return source
        case .wordCount(let wordCount): return wordCount
        }
    }

}

With the computed name and value properties in place, it is time to update the send(to:) method. We declare a variable dictionary with name properties. The dictionary has keys of type String and values of type Any. We set its initial value to an empty dictionary. We loop over the event's array of Property objects. For each property, we add a key-value pair to the properties dictionary using the computed name and value properties of the Property enum. With the properties dictionary populated, we can pass it to the trackEvent(with:properties:) method of the GoogleAnalyticsClient instance.

func send(to analyticsService: GoogleAnalyticsClient = .shared) {
    var properties: [String: Any] = [:]

    self.properties.forEach { property in
        properties[property.name] = property.value
    }

    analyticsService.trackEvent(with: name, properties: properties)
}

Using a Variadic Parameter

We also need to update the properties(_:) method of the Journey enum. Because the initializer of the Event struct expects an array of Property objects, we need to make a change. We could transform the properties dictionary to an array of Property objects, but that wouldn't be a good idea. It wouldn't benefit the API at the call site. Instead we change the type of the properties parameter to a variadic parameter of type Property. That is the only change we need to make.

func properties(_ properties: Property...) -> Event {
    Event(name: event, properties: properties)
}

This change has consequences at the call site, but those consequences are positive. Open NotesViewModel.swift and navigate to the createNote(_:) method. We no longer need to pass a dictionary of properties to the properties(_:) method. We pass it a list of Property objects instead. Xcode's autocompletion displays the list of supported properties and the associated values we defined ensure the compiler checks that the value of the property is of the correct type. That's a nice improvement.

func createNote(_ note: Note) {
    Journey.createNote
        .properties(
            .source("home"),
            .kind("template")
        ).send()
}

The API is starting to look much better. The code to send an event is easy to read and understand. We have reduced the risk for type errors at the call site and we only use string literals for the values of the properties.

Let's end this episode by updating the implementations of the updateNote(_:) and deleteNote(_:) methods. The changes are small, but the impact it has on the project is significant.

import Foundation

internal final class NotesViewModel {

    // MARK: - Public API

    func createNote(_ note: Note) {
        Journey.createNote
            .properties(
                .source("home"),
                .kind("template")
            ).send()
    }

    func updateNote(_ note: Note) {
        Journey.updateNote
            .properties(
                .source("home"),
                .kind("template"),
                .wordCount(note.wordCount)
            ).send()
    }

    func deleteNote(_ note: Note) {
        Journey.deleteNote
            .properties(
                .kind("template"),
                .wordCount(note.wordCount)
            ).send()
    }

}

What's Next?

The API we are building is shaping up nicely. We have drastically reduced the risk for typos and type errors by removing the need for string literals. The values of the kind and source properties are string literals and that is something we need to address. Not only can this be a source of bugs, it is not clear what the possible values are for the kind and source properties. We solve this elegantly in the next episode.