The JSONDecoder
and JSONEncoder
classes make working with JSON a breeze. The true star is Codable
, though. Codable
, a type alias for Decodable & Encodable
, is flexible and provides a lot of functionality for very little effort. From time to time, you run into a situation that makes you scratch your head. This episode covers one such situation. How do you encode null
using JSONEncoder
? The good news is that the solution is fairly straightforward.
Decoding Using JSONDecoder
Fire up Xcode and create a playground using the Blank template from the iOS section.
Remove the contents of the playground and add an import statement for the Foundation framework.
import Foundation
We define a struct, Book
, that conforms to the Codable
protocol. As I wrote earlier, Codable
is a type alias for Decodable & Encodable
, meaning that a type that conforms to Codable
conforms to Decodable
and Encodable
.
import Foundation
struct Book: Codable {
}
The Book
struct defines two properties of type String
, title
and author
. The conformance to Decodable
and Encodable
is generated for us.
import Foundation
struct Book: Codable {
// MARK: - Properties
let title: String
let author: String
}
Let's define a string literal and convert it to a Data
object.
let data =
"""
{
"title": "The Missing Manual for Swift Development",
"author": "Bart Jacobs"
}
""".data(using: .utf8)!
We create a JSONDecoder
instance and pass the Data
object to the decode(_:from:)
method. We instruct the JSON decoder to decode a Book
object from the Data
object.
let book = try JSONDecoder().decode(Book.self, from: data)
print(book)
This is what the output looks like in Xcode's console.
Book(title: "The Missing Manual for Swift Development", author: "Bart Jacobs")
Encoding Using JSONEncoder
Let's reverse the operation. We create a Book
object, create a JSONEncoder
instance, and pass the Book
object to its encode(_:)
method. We set the JSON encoder's outputFormatting
to prettyPrinted
to make the output in the console easier to read. We print the Data
object as a string.
let book = Book(
title: "The Missing Manual for Swift Development",
author: "Bart Jacobs"
)
let encoder = JSONEncoder()
encoder.outputFormatting = .prettyPrinted
let data = try encoder.encode(book)
print(String(data: data, encoding: .utf8)!)
The output is exactly what we expect.
{
"title" : "The Missing Manual for Swift Development",
"author" : "Bart Jacobs"
}
Let's say that it is possible for a book to be part of a series. Even though this book isn't part of a series, the API we submit the book to expects the following JSON payload. This isn't supported out of the box so we need to make a few changes.
{
"title" : "The Missing Manual for Swift Development",
"author" : "Bart Jacobs",
"series": null
}
Adding an Optional Property
We explore two solutions. The easiest solution is to declare a property series
of type String?
.
import Foundation
struct Book: Codable {
// MARK: - Properties
let title: String
let author: String
let series: String?
}
We also need to update the initializer of the Book
struct, passing in nil
for the series
parameter.
let book = Book(
title: "The Missing Manual for Swift Development",
author: "Bart Jacobs",
series: nil
)
You may thing this is sufficient, but it isn't. Take a look at the output. We need to make a few more changes.
{
"title" : "The Missing Manual for Swift Development",
"author" : "Bart Jacobs"
}
The series
field is missing from the JSON payload. To remedy that, we need to implement the encode(to:)
method of the Encodable
protocol. This also means we need to define the coding keys for the Book
struct.
struct Book: Codable {
// MARK: - Types
private enum CodingKeys: String, CodingKey {
case title, author, series
}
// MARK: - Properties
let title: String
let author: String
let series: String?
// MARK: - Encoding
func encode(to encoder: Encoder) throws {
var container = encoder.container(keyedBy: CodingKeys.self)
try container.encode(title, forKey: .title)
try container.encode(author, forKey: .author)
try container.encode(series, forKey: .series)
}
}
Implementing the encode(to:)
method does the trick as the output illustrates.
{
"title" : "The Missing Manual for Swift Development",
"author" : "Bart Jacobs",
"series" : null
}
This introduces a subtle code smell, though. The issue with this solution is that we declare the series
property for the sole purpose of resolving an encoding issue. This may be confusing since the series
property isn't being used in the project. Let's clean up the API and get rid of the code smell. We first remove the series
property.
struct Book: Codable {
// MARK: - Types
private enum CodingKeys: String, CodingKey {
case title, author, series
}
// MARK: - Properties
let title: String
let author: String
// MARK: - Encoding
func encode(to encoder: Encoder) throws {
var container = encoder.container(keyedBy: CodingKeys.self)
try container.encode(title, forKey: .title)
try container.encode(author, forKey: .author)
try container.encode(series, forKey: .series)
}
}
Don't forget to update the initializer.
let book = Book(
title: "The Missing Manual for Swift Development",
author: "Bart Jacobs"
)
The compiler doesn't like this and throws a few errors. Let's tackle the errors one by one.
You may think we need to pass nil
to the encode(_:forKey:)
method, but that results in another error.
The solution is to define a local constant, series
, of type String?
, set it to nil
, and pass the constant to the encode(_:forKey:)
method.
struct Book: Codable {
// MARK: - Types
private enum CodingKeys: String, CodingKey {
case title, author, series
}
// MARK: - Properties
let title: String
let author: String
// MARK: - Encoding
func encode(to encoder: Encoder) throws {
var container = encoder.container(keyedBy: CodingKeys.self)
try container.encode(title, forKey: .title)
try container.encode(author, forKey: .author)
let series: String? = nil
try container.encode(series, forKey: .series)
}
}
That change resolves the first error. Let's take a look at the second error. The compiler complains the Book
struct no longer conforms to the Decodable
protocol. Why is that?
The CodingKeys
enum defines three cases. If each case corresponds with a property, the conformance to the Decodable
protocol can be generated for us. That condition is no longer true, though. Because we removed the series
property, we need to implement the init(from:)
method of the Decodable
protocol.
We implement the initializer in an extension to ensure we don't lose the memberwise initializer that we get for free. You can read more about structs and memberwise initializers in What Is a Memberwise Initializer.
The implementation of the init(from:)
initializer isn't complex as you can see. Notice that we simply ignore the series
case in the initializer.
extension Book {
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
title = try container.decode(String.self, forKey: .title)
author = try container.decode(String.self, forKey: .author)
}
}
This change resolve the second error and the resulting JSON payload matches what we expected.
{
"title" : "The Missing Manual for Swift Development",
"author" : "Bart Jacobs",
"series" : null
}
What's Next?
Which solution you prefer depends on your requirements. If the optional property isn't an issue, then the first solution is sufficient. If the optional property may cause confusion, then the second solution is a better fit. While it may be tempting to opt for the first solution, make sure the API you create is clean and clear. Don't opt for the first solution because it requires less code.