swiftkeypaths

Swift KeyPath: Variable for Value Type Parameter


Suppose I have this simple class:

final class Foo 
{
    var title: String = ""
    var count: Int = 42
    …
}

I have a type-erased AnyKeyPath and I would like to cast it to a ReferenceWriteableKeyPath<Foo, String>, but I would like the value parameter to come from a variable, like this:

let erasedKeyPath: AnyKeyPath ... // Exists

let targetType: Any.Type = String.self

if let typedKeyPath = erasedKeyPath as? ReferenceWritableKeyPath<Foo, targetType> {
   ...
}

This throws an error: Cannot find type 'targetType' in scope.

Can this be done, somehow, or must KeyPath parameters always be explicitly typed out in code? I do not want to fallback on @dynamicMemberLookup.

Context

I’m building an abstraction layer that reconstitutes Swift model objects from Couchbase database records.

The Couchbase Swift SDK gives me a simple [String: Any?] where the keys are the property names of my model object.

I need to walk that dictionary and set the corresponding value on my model object. To do this, I have a macro that maps the name of each property to the appropriate KeyPath:

class var stringToKeyPathMap: [String: AnyKeyPath] {
    [“title”: \Foo.title, …]
}

The hitch is that because the KeyPaths have different valueType, they must be erased to AnyKeyPath for this collection. Critically: EVERY SINGLE KEYPATH has a rootType and valueType known at compile-time.

So,

  1. we walk the Couchbase dictionary and find the key-value pair for “title”.

  2. we look up the KeyPath for “title” and get back the appropriate AnyKeyPath.

  3. we can look up the type that this KeyPath addresses: type(of: kp).valueType and find that it’s String.self.

  4. we verify that the Couchbase dictionary value assigned to the key “title” is the type that we expect (a String).

  5. Now we can do this to set the value:

    if let writableKeyPath = erasedKeyPath as? ReferenceWritableKeyPath<Self, String> { self[keyPath: writableKeyPath] = stringFromCouchbaseDict }

  6. Great! But I had to manually specify the types in the cast. This means my framework can’t handle any type that I don’t explicitly type out on my keyboard—-EVEN THOUGH the AnyKeyPath has all the type info it needs! rootType and valueType exist and are valid and ready to go.

  7. The issue is about getting the AnyKeyPath to a ReferenceWritableKeyPath for any type. That lets my framework handle types that I don’t even know about, such as custom subclasses.


Solution

  • Instead of having the macro generate a dictionary mapping property names to key paths, have the macro generate the code that initialises the properties directly. e.g.

    @CouchbaseModel
    class X {
        var a: String
        var b: Int
    }
    

    expands to

    class X {
        var a: String
        var b: Int
    
        init(couchbaseData: [String: Any]) {
            self.a = couchbaseData["a"] as! String
            self.b = couchbaseData["b"] as! Int
        }
    }
    

    You can also include helpers like @CouchbaseIgnored to make @CouchbaseModel ignore a property, and @CouchbaseKey to specify the key name corresponding to that property in the dictionary from couchbase.

    Here is an example implementation:

    // declaration:
    
    @attached(peer)
    public macro CouchbaseKey(_ key: String) = #externalMacro(module: "...", type: "CouchbaseKey")
    
    @attached(peer)
    public macro CouchbaseIgnored() = #externalMacro(module: "...", type: "CouchbaseIgnored")
    
    @attached(member, names: named(init))
    public macro CouchbaseModel() = #externalMacro(module: "...", type: "CouchbaseModel")
    
    // implementation:
    
    struct PropertyMapping {
        let name: String
        let couchbaseKey: String
        let type: TypeSyntax
    }
    
    enum CouchbaseModel: MemberMacro {
        static func expansion(of node: AttributeSyntax, providingMembersOf declaration: some DeclGroupSyntax, in context: some MacroExpansionContext) throws -> [DeclSyntax] {
            let properties = declaration.memberBlock.members
                .compactMap { $0.decl.as(VariableDeclSyntax.self)?.propertyMapping }
            return [DeclSyntax(
                try InitializerDeclSyntax("init(couchbaseData: [String: Any])") {
                    for property in properties {
                        let keyLiteral = StringLiteralExprSyntax(content: property.couchbaseKey)
                        """
                        self.\(raw: property.name) = couchbaseData[\(keyLiteral)] as! \(property.type)
                        """
                    }
                }
            )]
        }
    }
    
    extension VariableDeclSyntax {
        var propertyMapping: PropertyMapping? {
            guard let binding = bindings.first,
                  let type = binding.typeAnnotation?.type,
                  let name = binding.pattern.as(IdentifierPatternSyntax.self)?.identifier.text else {
                return nil
            }
            if attributes.findAttribute("CouchbaseIgnored") != nil {
                return nil
            }
            guard let keyAttr = attributes.findAttribute("CouchbaseKey") else {
                return .init(name: name, couchbaseKey: name, type: type)
            }
            guard case let .argumentList(args) = keyAttr.arguments,
                  let key = args.first?.expression.as(StringLiteralExprSyntax.self)?.representedLiteralValue else {
                return nil
            }
            return .init(name: name, couchbaseKey: key, type: type)
        }
    }
    
    extension AttributeListSyntax {
        func findAttribute(_ name: String) -> AttributeSyntax? {
            for elem in self {
                if case let .attribute(attr) = elem,
                    attr.attributeName.as(IdentifierTypeSyntax.self)?.name.text == name {
                    return attr
                }
            }
            return nil
        }
    }
    
    // just a marker
    enum CouchbaseKey: PeerMacro {
        static func expansion(of node: AttributeSyntax, providingPeersOf declaration: some DeclSyntaxProtocol, in context: some MacroExpansionContext) throws -> [DeclSyntax] {
            []
        }
    }
    
    // just a marker
    enum CouchbaseIgnored: PeerMacro {
        static func expansion(of node: AttributeSyntax, providingPeersOf declaration: some DeclSyntaxProtocol, in context: some MacroExpansionContext) throws -> [DeclSyntax] {
            []
        }
    }
    

    In this example I generate force casts to the desired type, but it's not hard to handle type mismatches in some way. For example, you can write another helper macro that specifies a default value, or generate an init that throws.