- JSON everywhere
- How would Apple do it?
- Problems with Apple's approach
- Using enum to store all the strings
- Using
SwiftyJSON
to remove boilerplate SwiftyJSONModel
for the rescue- Type-safe and autocompleted keys for the
JSON
- Return Types are inferred
- Verbose errors
- Easy access to nested
JSON
- Conclusions
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!
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?🤔
Althought it seems quite straightforward and easy, current approach has several improtant drawbacks:
- 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
- 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 😤. - We never know where exactly the error happened. Our initializer is
Optional
and if something fails, we will just receivenil
. 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?
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:
- We introduced even more boilderplate. Now we have to write
PropertyKey.*enumCase*.rawValue
- 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:
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 keyfirstName
will be absent or wrong (for example there will beInt
instead ofString
) we will never be notified and in our casefirstName
will be just an empty string (like""
)- 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 withOptional casting
, but is still quite annoying. - Even more boilerplate with arrays.
arrayValue
property ofJSON
gives usArray
ofJSON
([JSON]
) so we need to manually map over it and getstringValue
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
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:
- 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 ourPropertyKey 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!
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.
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.
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
So let's recall all the things we gained from using SwiftyJSONModel:
- Keys for the
JSON
are now Type-safe - Removed all the boilerplate code
- Have better error handling system
- Easy to access nested
JSON
I really look forward to your feedback and of course, don't forget to fork me on github 😉
It is not needed now as we have Codable, Decodable protocol.