Storing a JSON / Codable type with SwiftData is (kinda) working out of the box but gets clunky when you start modifying the data which leaves you with a couple database migration errors which I’ll cover here with a possible solution to circumvent it

TLDR: Use Migrations or ValueTransformers

1. Context

Recently I’ve stored a Codable struct in SwiftData working on a private project and went with the initial thought that SwiftData is able to handle it by itself which turned out to be true until I’ve made a couple of changes (introduced some new properties, changed types) and ran into migrations errors. With some digging I’ve found a solution that makes it quite easy to handle and much easier to debug and fallback in case something goes eek.

2. Approach

Should be that easy right..

@Model 
final class Item {
    var timestamp: Date
    var meta: MetaData?
}

struct MetaData: Codable {
	var remix: Bool = true
}

This will work until you change your mind…

struct MetaData: Codable {
	var remix: Bool = true
    var createdAt: Date = Date()
}

Unresolved error loading container Error Domain=NSCocoaErrorDomain Code=134110 “An error occurred during persistent store migration.”

Validation error missing attribute values on mandatory destination attribute`

Eeeeek~

fail fail fail

fail fail fail

Might be solvable with SwiftData’s Migration but I went for a different route.

3. ValueTransformer

I’ve stumpled upon ValueTransformer which is supported via the @Attribute .transformable property wrapper which can transform values to the database in a custom way. Great fit for my case and useful for others as well.

A value transformer can take inputs of one type and return a value of a different type. For example, a value transformer could take an NSImage or UIImage object and return an NSData object containing the PNG representation of that image.

Apple Docs Reference

4. JSONValueTransformer

For convenience I’ve created a generic JSON ValueTransformer that encodes and decodes a Codable type via JSONEncoder and JSONDecoder and you’ll be able to actually intercept the value with the Decodable initializer.

class JSONValueClass: NSObject {}

final class JSONValueTransformer<M>: ValueTransformer where M: Codable {
    override class func transformedValueClass() -> AnyClass {
      return JSONValueClass.self
    }

    override class func allowsReverseTransformation() -> Bool {
        return true
    }

    override func transformedValue(_ value: Any?) -> Any? {
        guard let obj = value as? M else {
            return nil
        }

        do {
            let jsonEncoder = JSONEncoder()
            let data = try jsonEncoder.encode(obj)
            return data
        } catch {
            assertionFailure("Failed to transform `JSON` to `Data`")
            return nil
        }
    }

    override func reverseTransformedValue(_ value: Any?) -> Any? {
        guard let data = value as? NSData else {
            return nil
        }

        do {
            let jsonDecoder = JSONDecoder()
            let obj = try jsonDecoder.decode(M.self, from: data as Data)
            return obj
        } catch {
            assertionFailure("Failed to transform `Data` to `JSON`")
            return nil
        }
    }
}

extension JSONValueTransformer {
    static func register() {
        let transformer = JSONValueTransformer()
        let name = String(describing: JSONValueTransformer.self)
        ValueTransformer
            .setValueTransformer(
                transformer,
                forName: NSValueTransformerName(rawValue: name)
            )
    }
}

Let’s define a struct that should be used in the Model

struct MetaData: Codable {
    var active: Bool = false

    enum CodingKeys: String, CodingKey {
        case active
    }

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

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

Declare a typealias to make it reusable and less likely to misstype (define it where u feel it fits)

typealias MetaDataJSONValueTransformer = JSONValueTransformer<MetaData>

We can now define our model and use the @Attribute property wrapper with .transformable(by: ). This will make sure our property is transformed using the ValueTransformer we’ve just declared.

@Model 
final class Book {
    var name: String = ""

    @Attribute(.transformable(by: MetaDataJSONValueTransformer.self))
    var meta: MetaData
}

Finally let’s make sure we’re registering the ValueTransformer before we initialize ModelContainer

@main
struct ExampleApp: App {

    let container: ModelContainer

    init() {

        // don't forget this part
        MetaDataJSONValueTransformer.register()

        do {
			container = try ModelContainer(
                for: Book.self, 
                migrationPlan: ExampleMigrationPlan.self
            )
        } catch {
            fatalError("Failed to create ModelContainer")
        }

        // possible migration
        let context = ModelContext(container)
		for item in try context.fetch(FetchDescriptor<Item>()) {
			// ...
		}
    }

    var body: some Scene {
        WindowGroup {
            ContentView()
        }
        .modelContainer(container)
    }

    // ...

}

5. Changes

Introducing a new property like createdAt becomes straight forward and the behavior can be controlled

struct MetaData: Codable {
	var active: Bool = false
	var createdAt: Date?

	// ...

	init(from decoder: Decoder) throws {
		let container = try decoder.container(keyedBy: CodingKeys.self)
		self.active = try container.decode(Bool.self, forKey: .active)
		self.createdAt = try container.decodeIfPresent(Date.self, forKey: .createdAt)
	}

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

Last but not least migrate your data in case you want to update the data

// ExampleApp.swift
init() {

    // ...

    // migrate
    let context = ModelContext(container)
    for item in try context.fetch(FetchDescriptor<Item>()) {
        if item.meta.createdAt == nil {
            item.meta.createdAt = .now
        }
    }
}

6. Summary

ValueTransformer can be quite useful in various ways (not even just for this particularly this case) by storing / retrieving your SwiftData in custom defined behaviors.

- Cheers & Stay Motivated