Making your SwiftData models Codable

Published on: August 15, 2023

In a previous post, I explained how you can make your NSManagedObject subclasses codable. This was a somewhat tedious process that involves a bunch of manual work. Specifically because the most convenient way I've found wasn't all that convenient. It's easy to forget to set your managed object context on your decoder's user info dictionary which would result in failed saves in Core Data.

With SwiftData it's so much easier to define model objects so it makes sense to take a look at making SwiftData models Codable to see if it's better than Core Data. Ultimately, SwiftData is a wrapper around Core Data which means that the @Model macro will at some point generate managed objects, an object model, and more. In this post, we'll see if the @Model macro will also make it easier to use Codable with model objects.

If you prefer learning by video, check out the video for this post on YouTube:

Tip: if you're not too familiar with Codable or custom encoding and decoding of models, check out my post series on the Codable protocol right here.

Defining a simple model

In this post I would like to start us off with a simple model that's small enough to not get confusing while still being representative for a model that you might define in the real world. In my Practical Core Data book I make a lot of use of a Movie object that I use to represent a model that I would load from The Movie Database. For convenience, let's just go ahead and use the a simplified version of that:

@Model class Movie {
  let originalTitle: String
  let releaseDate: Date

  init(originalTitle: String, releaseDate: Date) {
    self.originalTitle = originalTitle
    self.releaseDate = releaseDate
  }
}

The model above is simple enough, it has only two properties and to illustrate the basics of using Codable with SwiftData we really don't need anything more than that. So let's move on and add Codable to our model next.

Marking a SwiftData model as Codable

The easiest way to make any Swift class or struct Codable is to make sure all of the object's properties are Codable and having the compiler generate any and all boilerplate for us. Since both String and Date are Codable and those are the two properties on our model, let's see what happens when we make our SwiftData model Codable:

// Type 'Movie' does not conform to protocol 'Decodable'
// Type 'Movie' does not conform to protocol 'Encodable'
@Model class Movie: Codable {
  let originalTitle: String
  let releaseDate: Date

  init(originalTitle: String, releaseDate: Date) {
    self.originalTitle = originalTitle
    self.releaseDate = releaseDate
  }
}

The compiler is telling us that our model isn't Codable. However, if we remove the @Model macro from our code we are certain that our model is Codable because our code does compiler without the @Model macro.

So what's happening here?

A macro in Swift expands and enriches our code by generating boilerplate or other code for us. We can right click on the @Model macro and choose expand macro to see what the @Model macro expands our code into. You don't have to fully understand or grasp the entire body of code below. The point of showing it is to show you that the @Model macro adds a lot of code, including properties that don't conform to Codable.

@Model class Movie: Codable {
  @_PersistedProperty
  let originalTitle: String
  @_PersistedProperty
  let releaseDate: Date

  init(originalTitle: String, releaseDate: Date) {
    self.originalTitle = originalTitle
    self.releaseDate = releaseDate
  }

  @Transient
  private var _$backingData: any SwiftData.BackingData<Movie> = Movie.createBackingData()

  public var persistentBackingData: any SwiftData.BackingData<Movie> {
    get {
      _$backingData
    }
    set {
      _$backingData = newValue
    }
  }

  static func schemaMetadata() -> [(String, AnyKeyPath, Any?, Any?)] {
    return [
      ("originalTitle", \Movie.originalTitle, nil, nil),
      ("releaseDate", \Movie.releaseDate, nil, nil)
    ]
  }

  required init(backingData: any SwiftData.BackingData<Movie>) {
    self.persistentBackingData = backingData
  }

  @Transient
  private let _$observationRegistrar = Observation.ObservationRegistrar()
}

extension Movie: SwiftData.PersistentModel {
}

extension Movie: Observation.Observable {
}

If we apply Codable to our SwiftData model, the protocol isn't applied to the small model we've defined. Instead, it's applied to the fully expanded macro. This means that we have several properties that don't conform to Codable which makes it impossible for the compiler to (at the time of writing this) correctly infer what it is that we want to do.

We can fix this by writing our own encoding and decoding logic for our model.

Writing your encoding and decoding logic

For a complete overview of writing custom encoding and decoding logic for your models, check out this post.

Let's start off by defining the CodingKeys enum that we'll use for both our encoding and decoding logic:

@Model class Movie: Codable {
  enum CodingKeys: CodingKey {
    case originalTitle, releaseDate
  }

  // ...
}

These coding keys directly follow the property names for our model. We have to define them because we're defining custom encoding and decoding logic.

The decoding init can look as follows:

required init(from decoder: Decoder) throws {
  let container = try decoder.container(keyedBy: CodingKeys.self)
  self.originalTitle = try container.decode(String.self, forKey: .originalTitle)
  self.releaseDate = try container.decode(Date.self, forKey: .releaseDate)
}

This initializer is pretty straightforward. We grab a container from the decoder, and then we ask the container to decode the properties we're interested in using our coding keys.

The encoding logic would look as follows:

func encode(to encoder: Encoder) throws {
  var container = encoder.container(keyedBy: CodingKeys.self)
  try container.encode(originalTitle, forKey: .originalTitle)
  try container.encode(releaseDate, forKey: .releaseDate)
}

With this initializer and encode(to:) function in place, our model is now fully Codable. Note that if you're only grabbing data from the network and which to decode that data into SwiftData models you can conform to Decodable instead of Codable in order to skip having to write the encode(to:) method.

Let's see how we can actually use our model next.

Decoding JSON into a SwiftData model

For the most part, decoding your JSON data into a SwiftData model will be relatively striaghtforward. The key thing to keep in mind is that you need to register all of your decoded objects in your model context after decoding them. Here's an example of how to do this:

let url = URL(string: "https://path.to.data")!
let (data, _) = try await URLSession.shared.data(from: url)

// this is the actual decoding
let movies = try! JSONDecoder().decode([Movie].self, from: data)

// don't forget to register the decoded objects
for movie in movies {
  context.insert(movie)
}

Making our model Codable and working with it was straightforward enough. To wrap things up, I'd like to explore how this approach works with relationships.

Adding relationships to our model

First, let's update our model object to have a relationship:

@Model class Movie: Codable {
  enum CodingKeys: CodingKey {
    case originalTitle, releaseDate, cast
  }

  let originalTitle: String
  let releaseDate: Date

  @Relationship([], deleteRule: .cascade)
  var cast: [Actor]

  init(originalTitle: String, releaseDate: Date, cast: [Actor]) {
    self.originalTitle = originalTitle
    self.releaseDate = releaseDate
    self.cast = cast
  }

  required init(from decoder: Decoder) throws {
    let container = try decoder.container(keyedBy: CodingKeys.self)
    self.originalTitle = try container.decode(String.self, forKey: .originalTitle)
    self.releaseDate = try container.decode(Date.self, forKey: .releaseDate)
    self.cast = try container.decode([Actor].self, forKey: .cast)
  }

  func encode(to encoder: Encoder) throws {
    var container = encoder.container(keyedBy: CodingKeys.self)
    try container.encode(originalTitle, forKey: .originalTitle)
    try container.encode(releaseDate, forKey: .releaseDate)
    try container.encode(cast, forKey: .cast)
  }
}

The Movie object here has gained a new property cast which is annotated with SwiftData's @Relationship macro. Note that the decode and encode logic doesn't get fancier than it needs to be. We just decode and encode our cast property like we would any other property.

Let's look at the definition of our Actor model next:

@Model class Actor: Codable {
  enum CodingKeys: CodingKey {
    case name
  }

  let name: String

  @Relationship([], deleteRule: .nullify)
  let movies: [Movie]

  init(name: String, movies: [Movie]) {
    self.name = name
    self.movies = movies
  }

  required init(from decoder: Decoder) throws {
    let container = try decoder.container(keyedBy: CodingKeys.self)
    self.name = try container.decode(String.self, forKey: .name)
  }

  func encode(to encoder: Encoder) throws {
    var container = encoder.container(keyedBy: CodingKeys.self)
    try container.encode(name, forKey: .name)
  }
}

Our Actor defines a relationship back to our Movie model but we don't account for this in our encode and decode logic. The data we're loading from an external source would infinitely recurse from actor to movie and back if actors would also hold lists of their movies in the data we're decoding. Because the source data doesn't contain the inverse that we've defined on our model, we don't decode it. SwiftData will make sure that our movies property is populated because we've defined this property using @Relationship.

When decoding our full API response, we don't need to update the usage code from before. It looks like we don't have to explicitly insert our Actor instances into our model context due to SwiftData's handling of relationships which is quite nice.

With the code as it is in this post, we can encode and decode our SwiftData model objects. No magic needed!

In Summary

All in all I have to say that I'm a little sad that we didn't get Codable support for SwiftData objects for free. It's nice that it's easier to make SwiftData models Codable than it is to make an NSManagedObject conform to Codable but it's not too far off. We still have to make sure that we associate our decoded model with a context. It's just a little bit easier to do this in SwiftData than it is in Core Data.

If you have a different approach to make your SwiftData models Codable, or if you have questions about this post feel free to reach out!

Categories

SwiftData

Subscribe to my newsletter