Skip to content

Instantly share code, notes, and snippets.

@alickbass
Last active October 10, 2022 19:22
Show Gist options
  • Save alickbass/b0683c175d6078d2a3bdb4ca005f56da to your computer and use it in GitHub Desktop.
Save alickbass/b0683c175d6078d2a3bdb4ca005f56da to your computer and use it in GitHub Desktop.
Working with JSON in Swift in a more type-safe way

Type safe JSON in Swift with SwiftyJSONModel

Table of contents

  1. JSON everywhere
  2. How would Apple do it?
  3. Problems with Apple's approach
  4. Using enum to store all the strings
  5. Using SwiftyJSON to remove boilerplate
  6. SwiftyJSONModel for the rescue
  7. Type-safe and autocompleted keys for the JSON
  8. Return Types are inferred
  9. Verbose errors
  10. Easy access to nested JSON
  11. Conclusions

JSON everywhere

Every app now heavily relies on transferring data through internet. No need to explain it 😉. And, of course, the most popular format is JSON. In Swift and Objective-C we need to parse JSON first then map it to native objects and then work with it.

Let's be more specific and consider the following example:

{
  "firstName": "Oleksii",
  "lastName": "Dykan",
  "age": 24,
  "isMarried": false,
  "height": 170.0,
  "hobbies": ["bouldering", "guitar", "swift:)"]
}

Imagine, that the back-end you're working with provides you the JSON above. This is the representation of the Person model and has just standard fields. In our app we would like to view the details of that person and we would like to map it to the following model:

struct Person {
    let firstName: String
    let lastName: String
    let age: Int
    let isMarried: Bool
    let height: Double
    let hobbies: [String]
}

Please note! I chose struct here, but the example can be applied to any type such as class, enum etc.

So let's try to make it work!

How would Apple do it?

The best way to solve iOS-related problem is to look for the solution in Apple's documentation. We would find a really nice article on swift's blog that is called exactly how we want: Working with JSON in Swift

To make long story short, Apple reccomends to use Foundation's framework JSONSerialization to convert Data into swift's native objects. In our case it would look like this:

let data: Data // received from a network request, for example
let json = try? JSONSerialization.jsonObject(with: data, options: [])

let json in our case is of Type Any? and in order to work with it we would have to cast it to [String: Any] and then extract values.

If we read Apple's article further, we will see, that the actual mapping Apple suggests to do the following way:

extension Person {
    init?(json: [String: Any]) {
        guard let firstName = json["firstName"] as? String,
            let lastName = json["lastName"] as? String,
            let age = json["age"] as? Int,
            let isMarried = json["isMarried"] as? Bool,
            let height = json["height"] as? Double,
            let hobbies = json["hobbies"] as? [String]
            else {
                return nil
        }
        
        self.firstName = firstName
        self.lastName = lastName
        self.age = age
        self.isMarried = isMarried
        self.height = height
        self.hobbies = hobbies
    }
}

let data: Data // received from a network request, for example
let json = try? JSONSerialization.jsonObject(with: data, options: [])

if let json = json as? [String: Any] {
    let person = Person(json: json)
    print(person)
}

Here we create an extension to our Person model and add an initializer that takes as an argument a Dictionary that was provided to us by JSONSerialization framework.

Then we exctract all the properties from json Dictionary and cast them to Types that we expect, like here:

let firstName = json["firstName"] as? String

If casting fails at some point, the whole initialization fails and we return nil.

Then we assign all the values to the properties in our model, like:

self.firstName = firstName

And that's it! We are ready to use our Person model in our App.

But is this the best way?🤔

Problems with Apple's approach

Althought it seems quite straightforward and easy, current approach has several improtant drawbacks:

  1. All the keys are just raw strings. This means, that it is really easy to make a typo and never notice it as swift's compiler cannot help us with strings
  2. A lot of boilerplate. Over and over again we have to cast to String, Int and [String] and then assign the variables to our proprties in model. Really annoying 😤.
  3. We never know where exactly the error happened. Our initializer is Optional and if something fails, we will just receive nil. But in order to understand what exactly when wrong, we will have to debug the json, go through all the keys manually and see what is missing or what has different type. Can't it all be automated?

Can we handle all these cases?

Using enum to store all the strings

The first problem we would like to solve, is to remove the raw strings. Swift has a very nice feature as enum with RawValue. So we will keep all the key strings in the separate enum like this:

enum PropertyKey: String {
    case firstName, lastName, age, isMarried, height, hobbies
}

Every case in this enum is backed by a raw string. So now in order to get the string from enum case we have to acess it's rawValue:

print(PropertyKey.firstName.rawValue) // prints "firstName"

So now let's apply this approach and use it in our model:

extension Person {
    enum PropertyKey: String {
        case firstName, lastName, age, isMarried, height, hobbies
    }
    
    init?(json: [String: Any]) {
        guard let firstName = json[PropertyKey.firstName.rawValue] as? String,
            let lastName = json[PropertyKey.lastName.rawValue] as? String,
            let age = json[PropertyKey.age.rawValue] as? Int,
            let isMarried = json[PropertyKey.isMarried.rawValue] as? Bool,
            let height = json[PropertyKey.height.rawValue] as? Double,
            let hobbies = json[PropertyKey.hobbies.rawValue] as? [String]
            else {
                return nil
        }
        
        self.firstName = firstName
        self.lastName = lastName
        self.age = age
        self.isMarried = isMarried
        self.height = height
        self.hobbies = hobbies
    }
}

let data: Data // received from a network request, for example
let json = try? JSONSerialization.jsonObject(with: data, options: [])

if let json = json as? [String: Any] {
    let person = Person(json: json)
    print(person)
}

So now we don't have any raw strings anymore, which is good.

However, we still have several problems:

  1. We introduced even more boilderplate. Now we have to write PropertyKey.*enumCase*.rawValue
  2. It is still possible to use raw strings. Noone restricts us from using string instead of enum's case. So we the compiler can help only partially

Using SwiftyJSON to remove boilerplate

As we are good guys and we know what Open Source is, we will soon find out SwiftyJSON.

SwiftyJSON introduces a special type JSON instead of type-erased Any that we receive as a result of JSONSerialization. So in our case we will create json as the following:

import SwiftyJSON

let data: Data // received from a network request, for example
let json = JSON(data: data) // creates type JSON from Data

print(json)

Seems pretty straightforward. Now we don't need to cast to dictionary as we did before, and we can use JSON type directly in the initializer. So our Person model now becomes the following:

import SwiftyJSON

extension Person {
    enum PropertyKey: String {
        case firstName, lastName, age, isMarried, height, hobbies
    }
    
    init(json: JSON) {
        firstName = json[PropertyKey.firstName.rawValue].stringValue
        lastName = json[PropertyKey.lastName.rawValue].stringValue
        age = json[PropertyKey.age.rawValue].intValue
        isMarried = json[PropertyKey.isMarried.rawValue].boolValue
        height = json[PropertyKey.height.rawValue].doubleValue
        hobbies = json[PropertyKey.height.rawValue].arrayValue.map({ $0.stringValue })
    }
}

let data: Data // received from a network request, for example
let json = JSON(data: data) // creates type JSON from Data
let person = Person(json: json)

print(person)

This looks much nicer as we don't have annoying castings anymore and we use convenient methods that JSON has to get String, Int, Bool etc.

We removed quite a lot of boilerplate code, but, nevertheless, we still have quite a lot problems with the chosen approach:

  1. stringValue, intValue, boolValue give us non-optional values. That means that we will never know that something went wrong with our json. For example, if in our json value for key firstName will be absent or wrong (for example there will be Int instead of String) we will never be notified and in our case firstName will be just an empty string (like "")
  2. Still a lot of boilerplate with specifying the type of value. We still have to explicitely state stringValue, intValue etc. which is somewhat less boilerplate than we had before with Optional casting, but is still quite annoying.
  3. Even more boilerplate with arrays. arrayValue property of JSON gives us Array of JSON ([JSON]) so we need to manually map over it and get stringValue from each element.

SwiftyJSONModel for the rescue

With all the problems in mind, I decided to write a microframework on top of the SwiftyJSON. Let's take a look on how would the same model look like when using SwiftyJSONModel and then we'll discuss all the features that it introduced.

So now our Person model looks like this:

extension Person: JSONObjectInitializable {
    enum PropertyKey: String {
        case firstName, lastName, age, isMarried, height, hobbies
    }
    
    init(object: JSONObject<PropertyKey>) throws {
        firstName = try object.value(for: .firstName)
        lastName = try object.value(for: .lastName)
        age = try object.value(for: .age)
        isMarried = try object.value(for: .isMarried)
        height = try object.value(for: .height)
        hobbies = try object.value(for: .hobbies)
    }
}

let data: Data // received from a network request, for example
let json = JSON(data: data) // creates type JSON from Data
do {
    let person = try Person(json: json)
    print(person)
} catch let error {
    print(error)
}

Looks pretty easy. Now let's dive into details of what we actually gained with this approach

Type-safe and autocompleted keys for the JSON

As you might notice, now instead of using JSON Type from SwiftyJSON we now use a wrapper Type on top of JSON which is called JSONObject. As you can also see, JSONObject takes a generic Type JSONObject<PropertyKey>. This actually tells JSONObject which enum do we use to store our keys.

So what we gain:

  • Now JSONObject limits the keys only to the enum that we specified. This in turn introduces autocompletion feature for our keys:

Screencapture GIF

  • Removed boilerplate code. So now the compiler knows what enum we use, so there is no need to do PropertyKey.hobbies.rawValue now we can directly use: .hobbies and that's it.
  • Keys are now Type-Safe. That means that we no longer can use random raw strings as keys for our JSON. We are restricted to our PropertyKey enum and the compiler will give us compile-time error when we try to use invalid keys.

Looks nice! And it's just the beginning!

Return Types are inferred

Apart from the type-safe keys, we no longer have to write stringValue, intValue etc. The frameworks knows which types should it return as when you did:

let firstName: String

you already specified that firstName is String.

So now instead of:

firstName = json[PropertyKey.firstName.rawValue].stringValue

There is no need to specify stringValue and now you can just write the following:

firstName = try object.value(for: .firstName)

This removes quite a lot of annoying boilerplate that we had when we did the casting with Apple's approach and when we used SwiftyJSON alone as well. But that's not all.

Now for the arrays we don't need to map explicitly and convert to specific type.

So instead of:

hobbies = json[PropertyKey.height.rawValue].arrayValue.map({ $0.stringValue })

Now we do just:

hobbies = try object.value(for: .hobbies)

It works the same as with regular String. The compiler already knows that you expect an Array of Strings so there is no need to do it again yourself.

Verbose errors

Consider the following JSON:

{
  "firstName": "John",
  "lastName": false,
  "age": 24,
  "isMarried": false,
  "height": 170.0,
  "hobbies": ["bouldering", "guitar", "swift:)"]
}

Here we have an invalid value for key lastName as we expect it to be String, but instead we receive Bool. Before, there was no way for us developers to understand what exactly went wrong with the JSON and we had to debug quite a lot in order to understand what caused the problem.

However, now SwiftyJSONModel tells use which property exactly was invalid:

let data: Data // received from a network request, for example
let json = JSON(data: data) // creates type JSON from Data
do {
    let person = try Person(json: json)
    print(person)
} catch let error {
    print(error) // prints: [lastName]: Invalid element
}

As you can see, we now immediately understand what property was invalid in JSON and we can talk to our back-end developers or adjust our model respectively.

Easy access to nested JSON

Consider the following JSON

{
  "city": "NY",
  "country": {
    "name": "USA",
    "continent": {
      "name": "North America"
    }
  }
}

So we have a nested JSON here that goes 2 levels deep. However, we do not want to create separate Model for each nested object and we want just to map to the following Model:

struct Address {
    let city: String
    let country: String
    let continent: String
}

our microframework allows to do it quite easy:

extension Address: JSONObjectInitializable {
    enum PropertyKey: String {
        case city, country, continent
        case name
    }
    
    init(object: JSONObject<PropertyKey>) throws {
        city = try object.value(for: .city)
        country = try object.value(for: .country, .name)
        continent = try object.value(for: .country, .continent, .name)
    }
}

Here we can acess the object by the full keypath to it:

.country, .continent, .name

And in case of error, we will receive the following:

[country][continent][name]: Invalid element

Conclusions

So let's recall all the things we gained from using SwiftyJSONModel:

  1. Keys for the JSON are now Type-safe
  2. Removed all the boilerplate code
  3. Have better error handling system
  4. Easy to access nested JSON

I really look forward to your feedback and of course, don't forget to fork me on github 😉

@saalisumer
Copy link

It is not needed now as we have Codable, Decodable protocol.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment