How to encode and decode JSON data in Swift

Apr 08, 2023#swift#how-to

Swift Codable is a powerful feature introduced in Swift 4 that simplifies the process of encoding and decoding JSON data. With Codable, you can easily convert Swift objects to data representations and vice versa, without having to write complex serialization and deserialization code.

Codable is actually a composition of two protocols, Encodable and Decodable.

Let’s start with a simple example of encoding and decoding a Codable type. Suppose we have a struct called Person that has two properties: name and age. We can make it conform to Codable by adding it to the inheritance list:

struct Person: Codable {
    var name: String
    var age: Int
}

Now we can create an instance of Person and encode it to JSON data using the JSONEncoder class:

let alice = Person(name: "Alice", age: 25)
let encoder = JSONEncoder()
do {
    let data = try encoder.encode(alice)
    print(String(data: data, encoding: .utf8)!) // {"name":"Alice","age":25}
} catch {
    print(error)
}

We can also decode JSON data back to a Person instance using the JSONDecoder class:

let data = "{\"name\":\"Bob\",\"age\":30}".data(using: .utf8)!
let decoder = JSONDecoder()
do {
    let bob = try decoder.decode(Person.self, from: data)
    print(bob) // Person(name: "Bob", age: 30)
} catch {
    print(error)
}

That’s the basic usage of Codable. But what if we want to customize the encoding and decoding process? For example, what if we want to use snake_case instead of camelCase for the JSON keys? Or what if we want to omit some properties from the JSON output? Or what if we want to add some extra logic or validation when encoding or decoding?

To do that, we can use CodingKeys and custom methods. CodingKeys is an enum that conforms to CodingKey protocol. It defines the mapping between the property names and the JSON keys. We can use it to change the key names or exclude some properties from encoding or decoding. For example, suppose we want to use snake_case for the JSON keys and exclude the age property. We can define a CodingKeys enum inside our Person struct like this:

struct Person: Codable {
    var name: String
    var age: Int
    
    enum CodingKeys: String, CodingKey {
        case name
        // no case for age means it will be excluded
    }
}

Now if we encode an instance of Person, we will get a JSON output with only the name key:

let alice = Person(name: "Alice", age: 25)
let encoder = JSONEncoder()
do {
    let data = try encoder.encode(alice)
    print(String(data: data, encoding: .utf8)!) // {"name":"Alice"}
} catch {
    print(error)
}

And if we decode a JSON data with only the name key, we will get a Person instance with a default value for the age property:

let data = "{\"name\":\"Bob\"}".data(using: .utf8)!
let decoder = JSONDecoder()
do {
    let bob = try decoder.decode(Person.self, from: data)
    print(bob) // Person(name: "Bob", age: 0)
} catch {
    print(error)
}

To change the key names to snake_case, we can use a raw value for each case that matches the desired key name:

struct Person: Codable {
    var name: String
    var age: Int
    
    enum CodingKeys: String, CodingKey {
        case name = "full_name"
        case age = "years_old"
    }
}

Now if we encode an instance of Person, we will get a JSON output with snake_case keys:

let alice = Person(name: "Alice", age: 25)
let encoder = JSONEncoder()
do {
    let data = try encoder.encode(alice)
    print(String(data: data, encoding: .utf8)!) 
    // {"full_name":"Alice","years_old":25}
} catch {
    print(error)
}

And if we decode a JSON data with snake_case keys, we will get a Person instance with matching property values:

let data = "{\"full_name\":\"Bob\",\"years_old\":30}".data(using: .utf8)!
let decoder = JSONDecoder()
do {
    let bob = try decoder.decode(Person.self, from: data)
    print(bob) // Person(name: "Bob", age: 30)
} catch {
    print(error)
}

To add some extra logic or validation when encoding or decoding, we can implement our own methods for encode(to:) and init(from:). These methods take a parameter of type Encoder or Decoder, respectively. We can use the container methods of these types to access the coding keys and the encoded or decoded values. For example, suppose we want to encode or decode a Person instance only if the name is not empty and the age is positive. We can implement our own encode(to:) and init(from:) methods like this:

struct Person: Codable {
    var name: String
    var age: Int
    
    enum CodingKeys: String, CodingKey {
        case name
        case age
    }
    
    func encode(to encoder: Encoder) throws {
        // check if the name is not empty
        guard !name.isEmpty else {
            throw EncodingError.invalidValue(name, EncodingError.Context(codingPath: [CodingKeys.name], debugDescription: "Name cannot be empty"))
        }
        // check if the age is positive
        guard age > 0 else {
            throw EncodingError.invalidValue(age, EncodingError.Context(codingPath: [CodingKeys.age], debugDescription: "Age must be positive"))
        }
        // get a keyed container using the coding keys
        var container = encoder.container(keyedBy: CodingKeys.self)
        // encode the name and age values to the container
        try container.encode(name, forKey: .name)
        try container.encode(age, forKey: .age)
    }
    
    init(from decoder: Decoder) throws {
        // get a keyed container using the coding keys
        let container = try decoder.container(keyedBy: CodingKeys.self)
        // decode the name value from the container
        let name = try container.decode(String.self, forKey: .name)
        // check if the name is not empty
        guard !name.isEmpty else {
            throw DecodingError.valueNotFound(String.self, DecodingError.Context(codingPath: [CodingKeys.name], debugDescription: "Name cannot be empty"))
        }
        // decode the age value from the container
        let age = try container.decode(Int.self, forKey: .age)
        // check if the age is positive
        guard age > 0 else {
            throw DecodingError.valueNotFound(Int.self, DecodingError.Context(codingPath: [CodingKeys.age], debugDescription: "Age must be positive"))
        }
        // initialize the person with the decoded values
        self.init(name: name, age: age)
    }
}

Now if we try to encode or decode a Person instance with an invalid name or age value, we will get an error:

let alice = Person(name: "", age: 25) // invalid name
let encoder = JSONEncoder()
do {
    let data = try encoder.encode(alice)
} catch {
    print(error) // invalidValue("", Swift.EncodingError.Context(codingPath: [__lldb_expr_1.Person.(CodingKeys in _D5F7C9E8C4F6B8B9F0A9E3D7B2A6A8C1).name], debugDescription: "Name cannot be empty", underlyingError: nil))
}

let data = "{\"name\":\"Bob\",\"age\":-1}".data(using: .utf8)! // invalid age
let decoder = JSONDecoder()
do {
    let bob = try decoder.decode(Person.self, from: data)
} catch {
    print(error) // valueNotFound(Swift.Int, Swift.DecodingError.Context(codingPath: [__lldb_expr_1.Person.(CodingKeys in _D5F7C9E8C4F6B8B9F0A9E3D7B2A6A8C1).age], debugDescription: "Age must be positive", underlyingError: nil))
}

That’s how we can use Codable to encode and decode custom types in Swift.