Adding Type Safety to User Defaults with Extensions

The defaults system is a convenient solution for storing small pieces of data, but it has its limitations. The defaults system only supports a handful of data types.

  • Data
  • Date
  • Number
  • String
  • Array
  • Dictionary

The interface of the UserDefaults class is easy to use, but it lacks type safety. If you want to store and retrieve an enum, for example, you need to jump through a few hoops. Let me show you an example from Cloudy, the application we build in Mastering MVVM With Swift.

In Cloudy's settings view, the user can choose how Cloudy formats temperatures. The user can choose between the Celsius scale and the Fahrenheit scale. To make this easier, the project declares an enum, TemperatureNotation. The raw values of TemperatureNotation are of type Int. Why that is important becomes clear in a moment.

import Foundation

enum TemperatureNotation: Int {

    // MARK: - Cases

    case celsius
    case fahrenheit

}

It isn't possible to store a TemperatureNotation object in the user's defaults database. To work around this limitation, Cloudy stores the user's setting as an integer. This is possible because the raw values of TemperatureNotation are of type Int.

// Define Setting
let setting: TemperatureNotation = .celsius

// Store Setting
UserDefaults.standard.set(setting.rawValue, forKey: "temperatureNotation")

Fetching the user's setting is similar. We ask the defaults system for the value of the temperatureNotation key and use that value to create a TemperatureNotation object.

// Fetch Setting
let rawValue = UserDefaults.standard.integer(forKey: "temperatureNotation")
let notation = TemperatureNotation(rawValue: rawValue)

Adding Type Safety with Extensions

To reduce code duplication and avoid string literals, we can take advantage of extensions. We define an extension for the UserDefaults class. Nested within the extension, we declare a private enum with name Keys. As the name suggests, the private enum defines the keys used in the user's defaults database.

extension UserDefaults {

    private enum Keys {

        static let temperatureNotation = "temperatureNotation"

    }

}

We declare a computed property, temperatureNotation, of type TemperatureNotation and define a getter and a setter.

extension UserDefaults {

    private enum Keys {

        static let temperatureNotation = "temperatureNotation"

    }

    // MARK: - Temperature Notation

    var temperatureNotation: TemperatureNotation {
        get {

        }
        set {

        }
    }

}

In the getter of the computed property, we ask the user's defaults database for the integer that is associated with the temperatureNotation key. The integer(forKey:) method returns 0 if the given key doesn't exist. We use the value returned by the integer(forKey:) method to create a TemperatureNotation object. Because the init(rawValue:) initializer is failable, we fall back to TemperatureNotation.celsius if the initialization fails.

extension UserDefaults {

    private enum Keys {

        static let temperatureNotation = "temperatureNotation"

    }

    // MARK: - Temperature Notation

    var temperatureNotation: TemperatureNotation {
        get {
            let rawValue = integer(forKey: Keys.temperatureNotation)
            return TemperatureNotation(rawValue: rawValue) ?? .celsius
        }
        set {

        }
    }

}

The implementation of the setter of the computed property couldn't be easier. We ask the TemperatureNotation object for its raw value and store the raw value in the user's defaults database.

extension UserDefaults {

    private enum Keys {

        static let temperatureNotation = "temperatureNotation"

    }

    // MARK: - Temperature Notation

    var temperatureNotation: TemperatureNotation {
        get {
            let rawValue = integer(forKey: Keys.temperatureNotation)
            return TemperatureNotation(rawValue: rawValue) ?? .celsius
        }
        set {
            setValue(newValue.rawValue, forKey: Keys.temperatureNotation)
        }
    }

}

The computed property provides type-safe access to the value stored in the defaults system. Objects interested in the setting don't need to work with raw values. They can ask the defaults system for a TemperatureNotation object.

Convenience and Elegance

I use this technique often and you can understand why if you take a look at these examples. Storing or retrieving the setting is concise, readable, and type-safe. The syntax looks and feels great.

// Read Setting
UserDefaults.standard.temperatureNotation

// Write Setting
UserDefaults.standard.temperatureNotation = .fahrenheit

Adding Combine to the Mix

We can take it a step further by reactifying the solution we implemented. For this to work, we need to declare another computed property that makes the temperatureNotation computed property available in Objective-C. The computed property is required because the TemperatureNotation enum cannot be represented in Objective-C. We make the raw value available in Objective-C through the rawTemperatureNotation computed property. Notice that rawTemperatureNotation is declared privately. There is no need for other objects to access the computed property. By applying the objc attribute, the compiler makes rawTemperatureNotation available in Objective-C. That also make the property key-value observing- or KVO-compliant.

extension UserDefaults {

    private enum Keys {

        static let temperatureNotation = "temperatureNotation"

    }

    // MARK: - Temperature Notation

    var temperatureNotation: TemperatureNotation {
        get {
            let rawValue = integer(forKey: Keys.temperatureNotation)
            return TemperatureNotation(rawValue: rawValue) ?? .celsius
        }
        set {
            setValue(newValue.rawValue, forKey: Keys.temperatureNotation)
        }
    }

    @objc private var rawTemperatureNotation: Int {
        get {
            integer(forKey: Keys.temperatureNotation)
        }
        set {
            setValue(newValue, forKey: Keys.temperatureNotation)
        }
    }

}

We define a publisher with name temperatureNotationPublisher. The publisher is of type AnyPublisher. Its Output type is TemperatureNotation and its Failure type is Never. In the body of the computed property, we create a publisher that emits a value when the value of a given key path changes. This only works if the key path is KVO-compliant. The publisher(for:) method accepts the key path as its first argument and an optional set of options as its second argument.

We apply the compactMap operator to transform the values emitted by the publisher to TemperatureNotation objects. We apply the compactMap operator instead of the map operator because the initialization of the TemperatureNotation object can fail. The Output type of the resulting publisher is TemperatureNotation. The eraseToAnyPublisher() method wraps the resulting publisher with a type eraser.

extension UserDefaults {

    private enum Keys {

        static let temperatureNotation = "temperatureNotation"

    }

    // MARK: - Temperature Notation

    var temperatureNotation: TemperatureNotation {
        get {
            let rawValue = integer(forKey: Keys.temperatureNotation)
            return TemperatureNotation(rawValue: rawValue) ?? .celsius
        }
        set {
            setValue(newValue.rawValue, forKey: Keys.temperatureNotation)
        }
    }

    var temperatureNotationPublisher: AnyPublisher<TemperatureNotation, Never> {
        publisher(for: \.rawTemperatureNotation)
            .compactMap { rawValue -> TemperatureNotation? in
                TemperatureNotation(rawValue: rawValue)
            }
            .eraseToAnyPublisher()
    }

    @objc private var rawTemperatureNotation: Int {
        get {
            integer(forKey: Keys.temperatureNotation)
        }
        set {
            setValue(newValue, forKey: Keys.temperatureNotation)
        }
    }

}

This solution opens up several possibilities. In this example, we combine temperatureNotationPublisher with a publisher that emits weather data. We apply the map operator to format the temperature to match the user's setting.

var temperaturePublisher: AnyPublisher<String?, Never> {
    weatherDataPublisher.combineLatest(UserDefaults.standard.temperatureNotationPublisher)
        .map { weatherData, temperatureNotation -> String? in
            switch temperatureNotation {
            case .fahrenheit:
                return String(format: "%.1f °F", weatherData.temperature)
            case .celsius:
                return String(format: "%.1f °C", weatherData.temperature.toCelcius)
            }
        }
        .eraseToAnyPublisher()
}

Let's put the cherry on the cake by moving the formatting to the TemperatureNotation enum. This is what that looks like. The format(temperature:from:) method accepts the temperature as its first argument and a TemperatureNotation object as its second argument. It uses a switch statement to figure out how the temperature should be formatted.

enum TemperatureNotation: Int {

    // MARK: - Cases

    case celsius
    case fahrenheit

    // MARK: - Public API

    func format(temperature: Double, from: Self) -> String {
        switch (self, from) {
        case (.celsius, .celsius):
            return String(format: "%.1f °C", temperature)
        case (.fahrenheit, .fahrenheit):
            return String(format: "%.1f °F", temperature)
        case (.celsius, .fahrenheit):
            return String(format: "%.1f °C", (temperature - 32.0) * (5.0 / 9.0))
        case (.fahrenheit, .celsius):
            return String(format: "%.1f °F", (temperature * (9.0 / 5.0) + 32.0))
        }
    }

}

With the format(temperature:from:) method in place, we can drastically simplify the implementation of temperaturePublisher. We use a map operator and shorthand argument syntax to format the raw values returned by the weather service into values the user understands.

var temperaturePublisher: AnyPublisher<String?, Never> {
    weatherDataPublisher.combineLatest(UserDefaults.standard.temperatureNotationPublisher)
        .map { $1.format(temperature: $0.temperature, from: .fahrenheit) }
        .eraseToAnyPublisher()
}

What's Next?

The Swift programming language provides developers with a wide range of tools. It is up to you, the developer, to use them in creative ways. The solutions we covered in this episode show what is possible. The only limitation is your creativity.