Chapters

Hide chapters

Expert Swift

First Edition · iOS 14 · Swift 5.4 · Xcode 12.5

8. Codable
Written by Shai Mishali

Heads up... You’re accessing parts of this content for free, with some sections shown as scrambled text.

Heads up... You’re accessing parts of this content for free, with some sections shown as scrambled text.

Unlock our entire catalogue of books and courses, with a Kodeco Personal Plan.

Unlock now

When developing your app, you’ll often deal with a myriad of data models and various external pieces of data that you’ll want to represent as data models in your app.

To solve this problem, you’ll often use a technique called serialization, where you create an external representation of your data models in one of many consumable formats supported by multiple platforms. The most popular of the bunch by far is JSON (Javascript Object Notation).

The need for data serialization in Apple’s platforms is so common that they solved it all the way back in Xcode 3.0, with the introduction of NSCoding, which lets you describe how to encode and decode your data.

Unfortunately NSCoding, while an incredible abstraction at the time, suffers from many issues and a lack of modern touch that fits the Swift world — such as automatically synthesized encoding and decoding, support for value types and more. Enter Codable.

What is Codable?

Codable is a type alias combining two protocols: Encodable and Decodable. These let you define how objects are encoded and decoded to and from an external data representation, such as JSON.

The great thing about Codable is that it’s mostly agnostic toward the format it encodes to and decodes from. It uses an additional set of abstractions called Encoder and Decoder to achieve this separation.

These abstractions own the specific intimate knowledge of how to encode and decode in and out of their specific data formats. For example, a JSONEncoder would know how to encode a given data model into a JSON response, while a PropertyListDecoder would know exactly how to take a plist file and decode it into a given data model.

This abstraction means that your objects only have to conform to Codable or either of its parts once, and can be encoded to and decoded from many different formats using various encoders and decoders.

JSON data Plist data Custom data JSONEncoder PropertyListEncoder PropertyListDecoder Custom encoders Custom decoders PERSON id: UUID name: String address: struct favorite: enum Conforms to Codable JSONDecoder

What you’ll learn, and what you won’t

Because this is an advanced book, you’ll quickly browse through the basic Codable knowledge, mostly focusing on the advanced materials down the dark corners of Codable.

Brushing up on the basics

When decoding or encoding a data structure, you often get to use Codable with literally no boilerplate.

{
  "name": "Shai Mishali",
  "twitter": "@freak4pc",
  "github": "https://github.com/freak4pc",
  "birthday": "October 4th, 1987"
}
struct Person {
  let name: String
  let twitter: String
  let github: URL
  let birthday: Date
}
struct Person: Codable {
  ...
}
// Decode
let decoder = JSONDecoder()
let person = try decoder.decode(Person.self, from: jsonData)

// Encode
let encoder = JSONEncoder()
let jsonData = try encoder.encode(person)

API #1: Ray’s Books

Getting started

Open the starter playground found in projects/starter and look at the Navigation pane on the left:

[
  {
    "id": "comb",
    "name": "Combine: Asynchronous Programming with Swift",
    "store_link": "https://store.raywenderlich.com/...",
    "authors": [
        "Scott Gardner",
        "Shai Mishali",
        "Forent Pillet",
        "Marin Todorov"
    ],
    "image_blob": "..."
  }
]

Basic decoding

Open the page 1. Ray’s Books. The easiest path to decoding the response is to start with properties you get “for free” from Swift.

struct Book: Decodable {
  let id: String
  let name: String
  let authors: [String]
}
let data = API.getData(for: .rwBooks)
let decoder = JSONDecoder()

do {
  let books = try decoder.decode([Book].self, from: data)
  print("—— Example of: Books ——")
  print(books)
} catch {
  print("Something went wrong: \(error)")
}
—— Example of: Books ——
__lldb_expr_1.Book(id: "comb", name: "Combine: Asynchronous Programming with Swift", authors: ["Scott Gardner", "Shai Mishali", "Forent Pillet", "Marin Todorov"]), ...])]

Key decoding strategies

Add the following property to Book:

let storeLink: URL
—— Example of: Books ——
keyNotFound(CodingKeys(stringValue: "storeLink", intValue: nil), ... debugDescription: "No value associated with key CodingKeys(stringValue: \"storeLink\", intValue: nil")
let decoder = JSONDecoder()
decoder.keyDecodingStrategy = .convertFromSnakeCase
—— Example of: Books ——
[__lldb_expr_5.Book(... storeLink: https://store.raywenderlich.com/products/combine-asynchronous-programming-with-swift), ...]

Data decoding strategies

Four keys decoded, and one final key to go — image_blob. This key contains a Base 64 representation of image data, so you can easily transport small thumbnails along with your JSON response.

decoder.dataDecodingStrategy = .base64
let imageBlob: Data
var image: UIImage? { UIImage(data: imageBlob) }
for book in books {
  print("\(book.name) (\(book.id))",
        "by \(book.authors.joined(separator: ", ")).",
        "Get it at: \(book.storeLink)")
  _ = book.image
}
—— Example of: Books ——
Combine: Asynchronous Programming with Swift (comb) by Scott Gardner, Shai Mishali, Forent Pillet, Marin Todorov. Get it at: https://store.raywenderlich.com/products/combine-asynchronous-programming-with-swift
...

Understanding coding keys

A CodingKey is a simple protocol describing how a key of a specific property is represented. It has two properties: stringValue, for string keys such as the ones you’ve just seen, and an optional intValue, for cases when the key is part of an array:

public protocol CodingKey {
  var stringValue: String { get }
  var intValue: Int? { get }

  init?(stringValue: String)
  init?(intValue: Int)
}
enum CodingKeys: String, CodingKey {
  case id, name, authors
  case storeLink = "store_link"
  case imageBlob = "image_blob"
}

Custom key decoding strategies

As mentioned earlier, there’s one final challenge for you to tackle in this section: creating your own decoding strategy.

let data = API.getData(for: .rwBooks)
let data = API.getData(for: .rwBooksKebab)
[...] No value associated with key CodingKeys(stringValue: \"storeLink\", intValue: nil) (\"storeLink\"), converted to store_link."
extension JSONDecoder.KeyDecodingStrategy {
  static var convertFromKebabCase: JSONDecoder.KeyDecodingStrategy = .custom({ keys in
    
  })
}
// 1
let codingKey = keys.last!
let key = codingKey.stringValue

// 2
guard key.contains("-") else { return codingKey }

// 3
let words = key.components(separatedBy: "-")
let camelCased = words[0] + 
                 words[1...].map(\.capitalized).joined()

return ???
struct AnyCodingKey: CodingKey {
  let stringValue: String
  let intValue: Int?

  init?(stringValue: String) {
    self.stringValue = stringValue
    self.intValue = nil
  }

  init?(intValue: Int) {
    self.intValue = intValue
    self.stringValue = "\(intValue)"
  }
}
return AnyCodingKey(stringValue: camelCased)!
decoder.keyDecodingStrategy = .convertFromSnakeCase
decoder.keyDecodingStrategy = .convertFromKebabCase

API #2: Magic: The Gathering

Magic: The Gathering was the first modern trading card game and it remains wildly popular among fans of the genre. Various cards have different powers, rarity, types and much more, and span over 20 different card sets.

🃏 Archangel of Thune #8
🃏 Thoughtseize #110
🃏 Batterskull #130
🃏 Force of Will #28
🃏 Krenko, Mob Boss #15
🃏 Rishkar’s Expertise #123

Decoding the card’s mana

Each card has something called a Mana Cost — the cost needed to put the card into play. The first card in the JSON response has a manaCost of {3}{W}{W}, which means three of any color (colorless), and two white mana cards.

extension Card {
  /// Card's Mana
  struct Mana: CustomStringConvertible {
    // 1
    let colors: [Color]

    // 2
    var description: String { colors.map(\.symbol).joined() }
  }
}

extension Card.Mana {
  /// Card's Mana Color
  enum Color {
    // 3
    case colorless(Int)
    case extra
    case white
    case blue
    case black
    case red
    case green

    // 4
    var symbol: String {
      switch self {
      case .white: return "W"
      case .blue: return "U"
      case .black: return "B"
      case .red: return "R"
      case .green: return "G"
      case .extra: return "X"
      case .colorless(let number): return "\(number)"
      }
    }
  }
}
init?(symbol: String) {
  if let value = Int(symbol) {
    self = .colorless(value)
    return
  }

  switch symbol.lowercased() {
  case "w":
    self = .white
  case "u":
    self = .blue
  case "b":
    self = .black
  case "r":
    self = .red
  case "g":
    self = .green
  case "x":
    self = .extra
  default:
    print("UNKNOWN \(symbol)")
    return nil
  }
}

Understanding containers

If you’ve ever written a custom Decodable or Encodable initializer, it’s quite likely you’ve worked with a decoding or encoding container, accordingly:

Custom Decodable conformance for Card.Mana

With the Card.Mana.Color initializer ready to roll, it’s time to start taking care of Card.Mana itself. Start by conforming Card.Mana to Decodable by replacing:

struct Mana: CustomStringConvertible {
struct Mana: Decodable, CustomStringConvertible {
init(from decoder: Decoder) throws {

}
let container = try decoder.singleValueContainer()
let cost = try container.decode(String.self)
self.colors = try cost
  .components(separatedBy: "}") // 1
  .dropLast()
  .compactMap { rawCost in
    let symbol = String(rawCost.dropFirst()) // 2

    // 3
    guard !symbol.isEmpty,
          let color = Color(symbol: symbol) else {
      throw DecodingError.dataCorruptedError(
        in: container,
        debugDescription: "Unknown mana symbol \(symbol)")
    }

    // 4
    return color
  }

Implementing the Mana object

Add the following property to Card:

let manaCost: Mana
print("🃏 \(card.name) #\(card.number), \(card.manaCost)")
🃏 Archangel of Thune #8, 3WW
🃏 Thoughtseize #110, B
🃏 Batterskull #130, 5
🃏 Force of Will #28, 3UU
🃏 Krenko, Mob Boss #15, 2RR
🃏 Rishkar’s Expertise #123, 4GG

Decoding the card’s rarity

The card rarity is mostly simple, comprising a fixed set of strings: Common, Mythic Rare, Basic Land etc.

extension Card {
  enum Rarity: String, CustomStringConvertible, Decodable {
    case common = "Common"
    case uncommon = "Uncommon"
    case rare = "Rare"
    case mythicRare = "Mythic Rare"
    case special = "Special"
    case land = "Basic Land"

    var description: String { rawValue }
  }
}
let rarity: Rarity
🃏 Archangel of Thune #8, 3WW, Mythic Rare
🃏 Thoughtseize #110, B, Rare
...
init(from decoder: Decoder) throws {
  let container = try decoder.container(keyedBy: CodingKeys.self)
  self.id = try container.decode(UUID.self, forKey: .id)
  self.name = try container.decode(String.self, forKey: .name)
  self.manaCost = try container.decode(Mana.self,
                                       forKey: .manaCost)
  self.type = try container.decode(String.self, forKey: .type)
  self.rarity = try container.decode(Rarity.self,
                                     forKey: .rarity)
  self.text = try container.decodeIfPresent(String.self,
                                            forKey: .text) ?? ""
  self.flavor = try container.decodeIfPresent(String.self,
                                              forKey: .flavor)
  self.number = try container.decode(String.self,
                                     forKey: .number)
  self.imageUrl = try container.decodeIfPresent(URL.self,
                                       forKey: .imageUrl)
}

enum CodingKeys: String, CodingKey {
  case id, name, manaCost, type, rarity
  case text, flavor, number, set, setName
  case power, toughness, rulings, imageUrl
}

Decoding the card’s set and attributes

Cards are released as part of sets, which contain a name and a symbol. Each creature card also features power and toughness, which determine how strong the creature’s attack is and how much damage it can withstand.

extension Card {
  struct Attributes {
    let power: String
    let toughness: String
  }
}

extension Card {
  struct Set {
    let id: String
    let name: String
  }
}
let set: Set
let attributes: Attributes?
// 1
// Set
self.set = Set(id: try container.decode(String.self,
                                        forKey: .set),
               name: try container.decode(String.self,
                                          forKey: .setName))

// 2
// Attributes
if let power = try container.decodeIfPresent(String.self,
                                             forKey: .power),
   let toughness = try container.decodeIfPresent(String.self,
                                           forKey: .toughness) {
  self.attributes = Attributes(power: power,
                               toughness: toughness)
} else {
  self.attributes = nil
}
print(
  "🃏 \(card.name) #\(card.number) is a \(card.rarity)",
  "\(card.type) and needs \(card.manaCost).",
  "It's part of \(card.set.name) (\(card.set.id)).",
  card.attributes.map { "It's attributed as \($0.power)/\($0.toughness)." } ?? ""
)
🃏 Archangel of Thune #8 is a Mythic Rare Creature — Angel and needs 3WW. It's part of Iconic Masters (IMA). It's attributed as 3/4.
🃏 Thoughtseize #110 is a Rare Sorcery and needs B. It's part of Iconic Masters (IMA). 
...

Decoding the card’s rulings

Each card possesses an array of rulings containing dates and textual rulings about the card throughout its history.

let rulings: [String]
// Rulings
let rulingDict = try container.decode([[String: String]].self,
                                      forKey: .rulings) // 1
self.rulings = rulingDict.compactMap { $0["text"] } // 2
print("Rulings: \(card.rulings.joined(separator: ", "))")
🃏 Archangel of Thune #8 is a Mythic Rare Creature — Angel and needs 3WW. It's part of Iconic Masters (IMA). It's attributed as 3/4.
Rulings: Archangel of Thune’s last ability triggers just once for each life-gaining event, whether it’s 1 life from Auriok Champion or 6 life from Tavern Swindler. [...]

API #3: Alpha Vantage

You’ll tackle the most challenging task last.

Exploring the starter page

In the navigation pane, open the page 3. Alpha Vantage. You’ll notice there’s already some starter code waiting there for you:

Analyzing the response

If you look in av_1min.json again, you’ll notice the structure looks similar to the following:

{
  "Meta Data": {
      "1. Information": "Intraday (1min) open...",
      "2. Symbol": "RAY",
      "3. Last Refreshed": "2020-08-14 20:00:00"
  },
  "Time Series (1min)": {
    "2020-08-14 20:00:00": {
        "1. open": "101.9000",
        "2. high": "102.0000",
        "3. low": "101.9000",
        "4. close": "102.0000",
        "5. volume": "1807"
    },
    [...]
  }
}
let info: String
let symbol: String

Decoding the nested metadata

Because the structure of Stock doesn’t directly correlate with the JSON response, you’ll need a custom decoding initializer. Add the following initializer, along with coding keys for the top level and the Meta Data level:

init(from decoder: Decoder) throws {
  // 1
  let container = try decoder.container(
    keyedBy: CodingKeys.self
  )
}

// 2
enum CodingKeys: String, CodingKey {
  case metaData = "Meta Data"
  case updates = "Time Series (1min)"
}

// 3
enum MetaKeys: String, CodingKey {
  case info = "1. Information"
  case symbol = "2. Symbol"
  case refreshedAt = "3. Last Refreshed"
}

Using nested containers

To start, add the following line inside your initializer:

let metaContainer = try container.nestedContainer(
    keyedBy: MetaKeys.self,
    forKey: .metaData
)
self.info = try metaContainer.decode(String.self, forKey: .info)
self.symbol = try metaContainer.decode(String.self,
                                       forKey: .symbol)
Stock(info: "Intraday (1min) [..]", symbol: "RAY")

Decoding custom date formats

Start by adding the following property to Stock:

let refreshedAt: Date
self.refreshedAt = try metaContainer.decode(
  Date.self,
  forKey: .refreshedAt
)
typeMismatch(...debugDescription: "Expected to decode Double but found a string/data instead."...)
let dateFormatter: DateFormatter = {
  let df = DateFormatter()
  df.dateFormat = "yyyy-MM-dd HH:mm:ss"
  return df
}()
decoder.dateDecodingStrategy = .formatted(dateFormatter)
Stock(info: "Intraday (1min) ..., symbol: "RAY", refreshedAt: 2020-08-14 17:00:00 +0000)

Decoding the individual stock updates

As before, it’s best to try and define what you want the data structure to eventually look like in Swift.

extension Stock {
  struct Update: Decodable, CustomStringConvertible {
    // 1
    let open: Float
    let high: Float
    let low: Float
    let close: Float
    let volume: Int
    var date = Date.distantPast

    // 2
    enum CodingKeys: String, CodingKey {
      case open = "1. open"
      case high = "2. high"
      case low = "3. low"
      case close = "4. close"
      case volume = "5. volume"
    }

    init(from decoder: Decoder) throws {
      let container = try decoder.container(keyedBy: CodingKeys.self)

      // 3
      self.open = try Float(container.decode(String.self,
                                             forKey: .open)).unwrapOrThrow()
      self.high = try Float(container.decode(String.self, forKey: .high)).unwrapOrThrow()
      self.low = try Float(container.decode(String.self, forKey: .low)).unwrapOrThrow()
      self.close = try Float(container.decode(String.self, forKey: .close)).unwrapOrThrow()
      self.volume = try Int(container.decode(String.self, forKey: .volume)).unwrapOrThrow()
    }

    // 4
    var description: String {
      "\(date)|o:\(open),h:\(high),l:\(low),c:\(close),v:\(volume)"
    }
  }
}

Decoding updates into Stock

Now, to deal with Stock itself. Start by adding the following property to it:

let updates: [Update]
let timesDictionary = try container.decode(
  [String: [String: String]].self,
  forKey: .updates
)

let timeKeys = timesDictionary.keys
  .compactMap(AnyCodingKey.init(stringValue:))
let timeContainer = try container.nestedContainer(
  keyedBy: AnyCodingKey.self,
  forKey: .updates
)

Decoding your new keys

Finalize your initializer with this piece of code:

// 1
self.updates = try timeKeys
  .reduce(into: [Update]()) { updates, currentKey in
    // 2
    var update = try timeContainer.decode(Update.self, 
                                          forKey: currentKey)
    // 3
    update.date = dateFormatter
          .date(from: currentKey.stringValue) ?? update.date
    // 4
    updates.append(update)
  }
  .sorted(by: { $0.date < $1.date }) // 5

Testing your Stock decoding

To test all of the incredible work you’ve just done, below Stock, replace print(stock) with the following:

print("\(stock.symbol), \(stock.refreshedAt):", 
      "\(stock.info) with \(stock.updates.count) updates")
for update in stock.updates {
  _ = update.open

  print("   >> \(update.date), O/C: \(update.open)/\(update.close), L/H: \(update.low)/\(update.high), V: \(update.volume)")
}
RAY, 2020-08-14 17:00:00 +0000: Intraday (1min) [...] with 100 updates
   >> 2020-08-14 15:02:00 +0000, O/C: 101.81/101.81, L/H: 101.81/101.81, V: 1020
   >> 2020-08-14 13:55:00 +0000, O/C: 101.95/102.09, L/H: 101.95/102.09, V: 1765
   >> ...

let stock = try getStock(interval: .oneMinute)
let stock = try getStock(interval: .fifteenMinutes)
... No value associated with key CodingKeys(stringValue: \"Time Series (1min)\", intValue: nil)[...]

Passing information with user-info keys

The question you need to answer is: “How can I pass information down from the external world into my decodable initializer?” That answer is simpler than you’d come to expect.

extension CodingUserInfoKey {
  static let timeInterval = CodingUserInfoKey(
    rawValue: "timeInterval"
  )!
}
decoder.userInfo = [.timeInterval: interval.rawValue]
let container = try decoder.container(
  keyedBy: CodingKeys.self
)
// 1
guard let time = decoder.userInfo[.timeInterval] as? Int else {
  throw DecodingError.dataCorrupted(
    .init(codingPath: [],
          debugDescription: "Missing time interval")
  )
}

// 2
let metaKey = AnyCodingKey(stringValue: "Meta Data")!
let timesKey = AnyCodingKey(stringValue: "Time Series (\(time)min)")!

// 3
let container = try decoder.container(
  keyedBy: AnyCodingKey.self
)
RAY, 2020-08-14 17:00:00 +0000: Intraday (15min) open, high, low, close prices and volume with 100 updates
...

Encoding

You’ve spent the majority of this chapter working on decoding because that’s where most of the challenges arise in daily work. But you’re definitely not going to leave this chapter without at least touching a bit on the other side of the equation: encoding.

Exploring the starter page

Open the 4. Encodable playground page in the Navigation pane and you’ll notice there’s already a considerable amount of boilerplate written for you:

{"street":"3828 Piermont Drive","atmCode":"1132","city":"Albuquerque",...}

Encoder customizations

Like JSONDecoder’s various customizable properties, JSONEncoder packs quite a punch. The first customization opportunity for you is output formatting. Add the following line immediately after creating your JSONEncoder:

encoder.outputFormatting = [.prettyPrinted,
                            .sortedKeys,
                            .withoutEscapingSlashes]
{
  "accessKey": "S|_|p3rs3cr37",
  "addedOn": 619552896.56153595,
  [...]
  "website": "http://github.com/freak4pc",
  "zip": 87112
}

Encoding strategies

Exactly like Decodable has decoding strategies, Encodable has encoding strategies: keyEncodingStrategy, dateEncodingStrategy and dataEncodingStrategy.

encoder.keyEncodingStrategy = .convertToSnakeCase
encoder.dateEncodingStrategy = .iso8601
"addedOn": 619553518.18203104
"added_on": "2020-08-19T18:12:23Z"

Customizing encoding with intermediate types

One thing you might have noticed is that two properties — accessKey and atmCode — contain rather sensitive information. It’s a good idea to encrypt this information before encoding the object to JSON.

struct EncryptedCodableString: ExpressibleByStringLiteral,
                               Codable {
  let value: String

  // 1
  let key = SymmetricKey(data:
                         "Expert Swift !!!".data(using: .utf8)!)

  // 2
  init(stringLiteral value: StringLiteralType) {
    self.value = value
  }

  // 3
  init(from decoder: Decoder) throws {
    let container = try decoder.singleValueContainer()
    let combined = try container.decode(Data.self)
    let result = try AES.GCM.open(.init(combined: combined),
                                  using: key)
    self.value = String(data: result, encoding: .utf8) ?? ""
  }

  // 4
  func encode(to encoder: Encoder) throws {
    var container = encoder.singleValueContainer()
    let data = value.data(using: .utf8)!
    let sealed = try AES.GCM.seal(data, using: key)
    try container.encode(sealed.combined)
  }
}
{
  "access_key" : "6CFbMLD0IojD7MaJwDH[...]iS4cr9i2vu0C2N/Q=",
  "atm_code": "mZjZ17+VM8Nh0e3DwceF8hfT/6gplOD+n5c/jpNVIws="
  [...]
}

Restructuring your output

To wrap up this chapter, you’ll learn about one final thing: How to manipulate the structure of the encoded JSON.

enum CustomerKeys: String, CodingKey {
  case name, accessKey, atmCode, addedOn, address, contactInfo
}

enum AddressKeys: String, CodingKey {
  case street, city, zip
}

enum ContactInfoKeys: String, CodingKey {
  case homePhone, cellularPhone, email
}
func encode(to encoder: Encoder) throws {
  var customer = encoder.container(keyedBy: CustomerKeys.self)
  try customer.encode(name, forKey: .name)
  try customer.encode(accessKey, forKey: .accessKey)
  try customer.encode(atmCode, forKey: .atmCode)
  try customer.encode(addedOn, forKey: .addedOn)
}
{
  "access_key": "AwENrgpbFvL[...]XS57sWpOQ==",
  "added_on": "2020-08-19T18:44:27Z",
  "atm_code": "AwFkTXStHHy[...]FoUOdAGHfjuUwkw==",
  "name": "Shai Mishali"
}

Encoding the customer’s information

Next, you’ll deal with the customer’s address and contact information. Can you guess how? Exactly like decoding, you can use nested containers to create nested structures in your encoding!

var address = customer.nestedContainer(
  keyedBy: AddressKeys.self,
  forKey: .address
)
try address.encode(street, forKey: .street)
try address.encode(city, forKey: .city)
try address.encode(zip, forKey: .zip)

var contactInfo = customer.nestedContainer(
  keyedBy: ContactInfoKeys.self,
  forKey: .contactInfo
)
try contactInfo.encode(homePhone, forKey: .homePhone)
try contactInfo.encode(cellularPhone, forKey: .cellularPhone)
try contactInfo.encode(email, forKey: .email)
{
  "access_key": "AwE4[...]DFL+m6NOPNw==",
  "added_on": "2020-08-19T18:50:26Z",
  "address": {
    "city": "Albuquerque",
    "street": "3828 Piermont Drive",
    "zip": 87112
  },
  "atm_code": "AwGw[..]Y7w==",
  "contact_info": {
    "cellular_phone": "+972 542-288-482",
    "email": "freak4pc@gmail.com",
    "home_phone": "+1 212-741-4695"
  },
  "name": "Shai Mishali"
}

Key points

You covered a lot in this chapter. Here are some of the key takeaways:

Have a technical question? Want to report a bug? You can ask questions and report bugs to the book authors in our official book forum here.
© 2024 Kodeco Inc.

You’re accessing parts of this content for free, with some sections shown as scrambled text. Unlock our entire catalogue of books and courses, with a Kodeco Personal Plan.

Unlock now