Handling routes from an API in a type-safe way with Swift

I’m working on an application that has to handle some kind of routing directions coming from an API in the following form:

{
    "title": "Tap to go to your profile!",
    "destination": "profile"
}

These items would show in a form of timeline of events, that when tapped, would take you to specific parts of the application.

I was thinking about how I could leverage Swift’s type-safety to make sure the application validates the destinations for each of these items and make a more robust solution for this.

Immediately, enums came to mind. I can easily define an enumeration with raw values:

enum Destination: String {
    case profile
    case info
    case feed
}

But then, I looked closely at the API’s documentation, and noticed that some destinations could come with attached metadata:

{
    "title": "Tap to go to Oscar's profile!",
    "destination": "profile:859928508374784884"
}

As you can probably guess, tapping on the item above should take you to the profile screen for the user with id 859928508374784884. I thought of enums with associated values, but that wouldn’t cut it. Associated values in enumerations prevent me from inflating instances from raw values.

I decided to come up with my alternative for enumerations to handle this specific case so I could build destination instances in a type-safe manner directly from the JSON objects coming from the API.


First, I started with a structure. Since I’m interested in being able to convert a raw string to an instance of my type and viceversa, RawRepresentable is the protocol to adopt.

struct Destination: RawRepresentable {
    private let _identifier: String
    
    var rawValue: String {
        return _identifier
    }
    
    init?(rawValue: String) {
        _identifier = rawValue
    }
}

Now, I need to check whether the raw value passed to the initializer is a valid one. For that, I defined a string set that contained all the possible identifiers for a Destination, and check for that on the failable initializer from RawRepresentable.

struct Destination: RawRepresentable {
    // ...
    
    private static let options: Set<String> = [
        "info",
        "profile",
        "feed",
        "event"
    ]
    
    init?(rawValue: String) {
        guard Destination.options.contains(rawValue) else {
            return nil
        }
        
        _identifier = rawValue
    }
}

The only thing missing right now would be handling the attached metadata for the available destinations.

For that, I added a value property that would hold the destination’s metadata (if any), and a couple more checks to the the initializer:

struct Destination: RawRepresentable {
    // ...
    var value: String?
    
    public var rawValue: String {
        return _identifier + (value != nil ? ":(value!)" : "")
    }
    
    init?(rawValue: String) {
        // Split the raw value
        let ids = rawValue.split(separator: ":")
            
        // Check that the identifier exists
        guard let identifier = ids.first else {
            return nil
        }
            
        // Convert from String.SubSequence
        let id = String(identifier)
        
        // Make sure this identifier is valid
        guard Destination.options.contains(id) else {
            return nil
        }
            
        _identifier = id
        
        // If metadata exists, add that metadata to value
        if ids.count == 2 {
            value = String(ids[1])
        }
    }
}

Now, conform with Codable so that Destination can be parsed from and to raw data:

struct Destination: RawRepresentable {
    // ...
    enum DestinationError: Error {
        case unsupportedDestination
        case wrongFormat
    }
    
    enum CodingKeys: String, CodingKey {
        case _identifier = "destination"
    }
    
    func encode(to encoder: Encoder) throws {
        var container = encoder.container(keyedBy: CodingKeys.self)
        
        try container.encode(_identifier, forKey: ._identifier)
    }
    
    init(from decoder: Decoder) throws {
        let container = try decoder.container(keyedBy: CodingKeys.self)
        let rawValue = try container.decode(String.self, forKey: ._identifier)
        let ids = rawValue.split(separator: ":")
        
        guard let identifier = ids.first else {
            throw DestinationError.wrongFormat
        }
        
        let id = String(identifier)
        
        guard Destination.options.contains(id) else {
            throw DestinationError.unsupportedDestination
        }
        
        _identifier = id
        
        if ids.count == 2 {
            value = String(ids[1])
        }
    }
}

Unfortunately, I coulnd’t find an elegant way to extract the initialization checks to another method (because scope initialization, etc), so the initializer from RawRepresentable has to be basically duplicated on the one from Decodable.

One last step would be making Destination conform with Hashable:

struct Destination: RawRepresentable, Codable, Hashable {
    // ...
    var hashValue: Int {
        return _identifier.hashValue
    }
}

func ==(lhs: Destination, rhs: Destination) -> Bool {
    return lhs.hashValue == rhs.hashValue
}

Note that hashValue takes only into account the identifier for the destination, not it’s attached value. That way, profile:859928508374784884 and a profile are considered equal, even though the first one has attached metadata.

This is super specific in this case, because I only care about the route and the metadata is treated as optional. However, in your own implementation, taking value into consideration for Hashable might make sense.

And that’s it. A quick hack would be to add static properties to Destination with prebuilt values so that they can be treated as enumeration cases:

struct Destination: RawRepresentable {
    static let info = Destination(rawValue: "info")!
    static let profile = Destination(rawValue: "profile")!
    static let feed = Destination(rawValue: "feed")!
    static let event = Destination(rawValue: "event")!
    
    static func profile(value: String) -> Destination? {
        return Destination.rawValue("profile:(value)")
    }
}

Here’s how all that comes together:

let json = ["destination": "profile:859928508374784884"]
let jsonData = try! JSONSerialization.data(withJSONObject: json, options: .prettyPrinted)

let decoder = JSONDecoder()
let parsedDestination = try? decoder.decode(Destination.self, from: jsonData)

let simpleDestination = Destination.profile
let profileDestination = Destination.profile(value: "859928508374784884")!

parsedDestination == profileDestination // #=> true

And of course, you can switch on Destination values as well:

switch parsedDestination! {
case .profile: 
    router.navigateToProfile(parsedDestination)
    
default:
    // Handle other cases.
}

I thought this could make for a nice example on how to leverage Swift’s flexibility to help when the default data structures are not compatible with our use cases.

If you have any comments or idea, or I missed something, feel free to ping me on Twitter @Swanros.

Thanks for reading!