NSCoding requires the NSObjectProtocol, which is a class protocol, and can’t be conformed to by structs. If we have anything we want to encode using NSCoding, the easiest thing do has always been to make it a class, and make it inherit from NSObject.

I’ve found a decent way to wrap a struct in an NSCoding container, and save that without much fuss. I’ll use Coordinate as an example.

struct Coordinate: JSONInitializable {
    let latitude: Double
    let longitude: Double
        
    init(latitude: Double, longitude: Double) {
        self.latitude = latitude
        self.longitude = longitude
    }
}

It’s a simple type, with two scalar properties. Let’s create a class that conforms to NSCoding and wraps a Coordinate.

class EncodableCoordinate: NSObject, NSCoding {
    
    var coordinate: Coordinate?
    
    init(coordinate: Coordinate?) {
        self.coordinate = coordinate
    }
    
    required init?(coder decoder: NSCoder) {
        guard
            let latitude = decoder.decodeObject(forKey: "latitude") as? Double,
            let longitude = decoder.decodeObject(forKey: "longitude") as? Double
            else { return nil }
        coordinate = Coordinate(latitude: latitude, longitude: longitude)
    }
    
    func encode(with encoder: NSCoder) {
        encoder.encode(coordinate?.latitude, forKey: "latitude")
        encoder.encode(coordinate?.longitude, forKey: "longitude")
    }
}

It’s nice to have this logic in another type, which adheres more strictly to the single responsibility principle. An astute reader of the class above can see that the coordinate property of the EncodableCoordinate is Optional, but it doesn’t have to be. We could make our intializer take a non-optional Coordinate (or make it failable), and our init(coder:) method is already failable, and then we would guarantee that we will always have a coordinate if we have an instance of the EncodableCoordinate class.

However, because of a peculiarity in the way NSCoder works, when encoding Double types (and other primitives), they can’t be extracted with decodeObject(forKey:) (which returns an Any?. They have to use their specific corresponding method, which for a Double is decodeDouble(forKey:). Unfortunately, these specific methods don’t return optionals, and they return 0.0 for any keys that are missing or otherwise corrupt. Because of this, I chose to keep the coordinate property as an optional, and encode it as optional, so that I could get objects of type Double? out using decodeObject(forKey:), ensuring a little extra safety.

Now, to encode and decode Coordinate objects, we can create an EncodableCoordinate, and write that to disk with NSKeyedArchiver:

let encodable = EncodableCoordinate(coordinate: coordinate)
let data = NSKeyedArchiver.archiveRootObject(encodable, toFile: somePath)

Having to make this extra object isn’t ideal, and I’d like to work with an object like SKCache from Cache Me If You Can, so if I can formalize the relationship between the encoder and the encoded, maybe I can avoid having to create the NSCoding container manually each time.

To that end, let’s add two protocols:

protocol Encoded {
    associatedtype Encoder: NSCoding
    
    var encoder: Encoder { get }
}

protocol Encodable {
    associatedtype Value
    
    var value: Value? { get }
}

And two conformances for our two types:

extension EncodableCoordinate: Encodable {
    var value: Coordinate? {
        return coordinate
    }
}

extension Coordinate: Encoded {
    var encoder: EncodableCoordinate {
        return EncodableCoordinate(coordinate: self)
    }
}

With these, the type system now knows how to convert back and forth between the types and values of the pair of objects.

class Cache<T: Encoded> where T.Encoder: Encodable, T.Encoder.Value == T {
	//...
}

The SKCache object from that blog post has been upgraded to be generic over some Encoded type, with the constraint that its encoder’s value’s type is itself, which enables bidirectional conversion between the two types.

The last piece of the puzzle is the save and fetch methods for this type. save involves grabbing the encoder (which is the actual NSCoding-conformant object), and saving it to some path:

func save(object: T) {
   NSKeyedArchiver.archiveRootObject(object.encoder, toFile: path)
}

Fetching involves a slight compiler dance. We need to cast the unarchived object to T.Encodable, which is the type of the encoder, and then grab its value, and dynamically cast it back to T.

func fetchObject() -> T? {
    let fetchedEncoder = NSKeyedUnarchiver.unarchiveObject(withFile: storagePath)
    let typedEncoder = fetchedEncoder as? T.Encoder
    return typedEncoder?.value as T?
}

Now, to use the cache, we instantiate one and make it generic over Coordinate:

let cache = Cache<Coordinate>(name: "coordinateCache")

Once we have that, we can transparently save and retrieve coordinate structs:

cache.save(object: coordinate)

Through this technique, we can encode structs through NSCoding, maintain conformance to the single responsibility principle, and enforce type-safety.