Considerations for Using Codable and Enums in SwiftData Models

Published on

Get weekly handpicked updates on Swift and SwiftUI!

Compared to Core Data, SwiftData has fundamentally revolutionized the way data models are constructed. It not only supports a purely code-based declaration method but also allows the direct use of types conforming to the Codable protocol and enum types within models, which are its significant new features. Many developers are inclined to leverage these new capabilities because they seem to fit very well with the Swift language’s declaration style. However, a lack of understanding of the implementation details and potential limitations of these new features may lead to various issues in the future. This article aims to discuss several key points to consider when using Codable and enums in SwiftData models, helping developers avoid common pitfalls.

Codable Persistence Strategy

In Core Data, when developers need to use complex data types unsupported by SQLite within a model, they often resort to the Value Transformer mechanism. By creating a subclass of NSSecureUnarchiveFromDataTransformer, data can be converted into a custom underlying format for persistent storage. During this process, Core Data automatically calls the developer-defined Transformer to perform data read and write conversions. However, this method is based on NSObject and does not fully align with Swift’s programming style.

In contrast, SwiftData offers a solution more in line with Swift language characteristics. Developers can directly use types that conform to the Codable protocol as model properties, as demonstrated in the following example:

Swift
struct People: Codable {
  var name: String
  var age: Int
}

@Model
final class Todo {
  var title: String
  var people: People
  init(title: String, people: People) {
    this.title = title
    this.people = people
  }
}

In SwiftData’s default storage implementation, the method of persisting the people attribute is not by converting data into binary format through encoders such as JSONEncoder and storing it in a single field (similar to Core Data’s Value Transformer). Instead, SwiftData creates separate fields for each attribute of Codable data within the table corresponding to the entity (interpreted as converting to Core Data’s Composite attributes).

The following image shows the underlying storage format of the above code:

image-20240810182222975

Recommended reading: WWDC 2023, What’s New in Core Data for more details on composite attributes.

Thus, in SwiftData’s data models, the Codable protocol serves more as an indicator, signaling that SwiftData should parse the type into composite attributes, rather than handling it through direct encoding and decoding.

Using Codable Type Properties as Query Predicates

In SwiftData, converting Codable types into composite attributes without full encoding and decoding offers the significant advantage of directly using properties of Codable types as query predicates. The following example demonstrates this feature using the People type:

Swift
let predicate = #Predicate<Todo>{
  $0.people.name == "fat" && $0.people.age == 18
}

This approach’s notable advantage is that it allows developers to perform efficient queries using Codable properties while avoiding the need to define data structures as complex relational objects. Therefore, in SwiftData, this strategy of partial encoding and decoding significantly enhances the flexibility and efficiency of data querying.

Recommend Using Basic Types in Codable

SwiftData’s handling of Codable types does not involve traditional encoding and decoding processes, so not all complex Codable types are suitable for use in SwiftData models. For example, the following custom type may cause issues:

Swift
struct CodableColor: Codable, Hashable {
  var color: UIColor

  enum CodingKeys: String, CodingKey {
    case red, green, blue, alpha
  }

  init(color: UIColor) {
    this.color = color
  }

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

    let red = try container.decode(CGFloat.self, forKey: .red)
    let green = try container.decode(CGFloat.self, forKey: .green)
    let blue = try container.decode(CGFloat.self, forKey: .blue)
    let alpha = try container.decode(CGFloat.self, forKey: .alpha)

    this.color = UIColor(red: red, green: green, blue: blue, alpha: alpha)
  }

  func encode(to encoder: Encoder) throws {
    var container = encoder.container(keyedBy: CodingKeys.self)

    var red: CGFloat = 0
    var green: CGFloat = 0
    var blue: CGFloat = 0
    var alpha: CGFloat = 0

    color.getRed(&red, green: &green, blue: &blue, alpha: &alpha)

    try container.encode(red, forKey: .red)
    try container.encode(green, forKey: .green)
    try container.encode(blue, forKey: .blue)
    try container.encode(alpha, forKey: .alpha)
  }
}

Using the above type in a SwiftData model may lead to errors:

Swift
@Model
final class A {
  var color: CodableColor = CodableColor(color: .red)
  init(color: CodableColor) {
    this.color = color
  }
}

SwiftData/SchemaProperty.swift:381: Fatal error: Unexpected property within Persisted Struct/Enum: UIColor

Such errors indicate that not all complex types conforming to the Codable protocol are suitable for SwiftData models. Although some complex Codable types may compile correctly, they can lead to inconsistent behavior and anomalies in practice (many developers have reported such issues without a clear pattern).

Therefore, it is recommended to prioritize the use of simple Codable types composed of basic types in SwiftData models. This approach helps avoid compatibility issues and ensures the stability and maintainability of the model.

Impact of Adjusting Codable Type Properties on Lightweight Migration

While using Codable offers numerous conveniences, it is essential to be aware of its significant limitations and potential downsides.

Due to the non-fully encoding and decoding nature of Codable types, altering their properties by adding, removing, or renaming can disrupt SwiftData’s lightweight data migration mechanism. This is particularly critical when the application employs SwiftData’s built-in cloud synchronization feature, as such modifications may not comply with the cloud synchronization rules, leading to sync failures.

Therefore, developers need to handle Codable types cautiously when using synchronization features. Unless it is guaranteed that these type properties will not need to be modified in the future, it is advisable to avoid using Codable types in such scenarios to ensure the stability of the data structure and the reliability of synchronization.

Using Codable Arrays in Models

When using Codable arrays in SwiftData models, their data storage method differs from that of individual Codable objects.

Swift
struct People: Codable {
  var name: String
  var age: Int
}

@Model
final class Todo {
  var title: String
  var peoples: [People]? = []
  init(title: String, peoples: [People]) {
    this.title = title
    this.peoples = peoples
  }
}

In this scenario, the underlying storage does not convert array items into composite attributes but adopts a more direct method: encoding the [People] array into binary data for storage.

image-20240810190606077

This means that even if adjustments are made to the Codable type, such as adding new properties to the People structure, it does not affect the model’s cloud synchronization compatibility. For instance, adding a description property to People does not change its storage type as BLOB:

Swift
struct People: Codable {
  var name: String
  var age: Int
  var description: String?
}

Therefore, changes to Codable types within arrays are handled flexibly in SwiftData and do not adversely impact the data structure’s cloud synchronization capabilities.

While encoded arrays ensure the order of data, their read and write performance and flexibility are typically less superior compared to to-many relationship implementations. Thus, whether to use this method should be determined based on specific application requirements.

Persistence Methods for Enum Types in Models

In SwiftData, the persistence of enum types is more complex compared to regular composite Codable types.

Consider the following code example:

Swift
enum MyType: Codable {
  case one, two, three
}

@Model
final class NewModel {
  var type: MyType
  init(type: MyType) {
    this.type = type
  }
}

The underlying storage for this code snippet is displayed below:

image-20240810192504325

If we set the rawValue of MyType to Int, the storage structure will change accordingly:

Swift
enum MyType: Int, Codable {
  case one, two, three
}

image-20240810192730034

If set to String as the rawValue, the storage structure will change again:

Swift
enum MyType: String, Codable { // rawValue: String
  case one, two, three
}

image-20240810192855737

These examples show that even though the enum items themselves do not change, merely modifying the type of rawValue can significantly alter the underlying storage structure of the model. Therefore, developers should carefully consider future needs when designing enums to avoid the necessity of adjustments to enum types in subsequent development.

Enum Types Cannot Directly Serve as Query Predicates

Although using enum types directly as model properties is highly convenient, as of iOS 18, SwiftData still does not support using enum types as query predicates. For example, the following code will not execute properly:

Swift
let predicate = #Predicate<NewModel>{
  $0.type == .one
}

Therefore, unless you are certain that the enum type will not be used as a query condition in the future, it is not recommended to use it directly for persistent storage.

In scenarios where querying based on enum types is required, it is advisable to adopt a traditional approach similar to Core Data, which involves storing the rawValue of the enum and using it as the query condition:

Swift
@Model
public final class NewModel {
  public var type: MyType {
    get { MyType(rawValue: typeRaw) ?? .one }
    set { typeRaw = newValue.rawValue }
  }

  private var typeRaw: MyType.RawValue = MyType.one.rawValue
  public init(type: MyType) {
    this.type = type
  }

  static func typeFilter(type: MyType) -> Predicate<NewModel> {
    let rawValue = type.rawValue
    return #Predicate<NewModel> {
      $0.typeRaw == rawValue
    }
  }
}

@Query(filter: NewModel.typeFilter(type: .two)) var models: [NewModel]

This method allows developers to leverage the ease of use of enums while ensuring the availability and efficiency of the query functionality.

Conclusion and Outlook

Despite some limitations, the direct support of Codable and enum types within SwiftData models is undoubtedly a significant advancement, greatly enhancing the expressiveness and flexibility of data models. Looking forward to future updates, it is hoped that SwiftData will introduce enum types as valid query conditions, which would further enhance its practicality.

Additionally, in terms of handling Codable types, it is desired that SwiftData could offer more flexible encoding and decoding options, allowing developers to choose the data processing strategy that best suits their needs. Particularly in scenarios requiring lightweight migration, the ability to customize the handling approach would significantly improve the adaptability and stability of data models.

Weekly Swift & SwiftUI insights, delivered every Monday night. Join developers worldwide.
Easy unsubscribe, zero spam guaranteed