iosswiftgenericsswift-keypath

Key path value type 'Int' cannot be converted to contextual type 'String'


I am trying to pass a multiple tuples containing a KeyPath and a type of a sort order to a method that should do sorting.

I have this method:

extension Array {
    mutating func sort<T: Comparable>(by criteria: (path: KeyPath<Element, T>, order:OrderType)...) {
        
        criteria.forEach { path, order in
            //...
            sort { first, second in
            order.makeComparator()(
                first[keyPath: path],
                second[keyPath: path]
            )
        }
        }
    }
}

and I am using it like this:

var posts = BlogPost.examples
        
posts.sort(by:(path:\.pageViews, order: .asc), (path:\.sessionDuration, order: .desc))

Now, cause both pageViews and sessionDuration properties are integers, this will work.

But if I want to pass two properties of different types (say String and Int), I am getting this error:

Key path value type 'Int' cannot be converted to contextual type 'String'

Here is rest of the code, but I guess is not that relevant:

enum OrderType: String {
    case asc
    case desc
}

extension OrderType {
    func makeComparator<T: Comparable>() -> (T, T) -> Bool {
        switch self {
        case .asc:
            return (<)
        case .desc:
            return (>)
        }
    }
}

How should I define sort method so that I it accept heterogenous key paths?


Solution

  • In Swift 5.9, you can write such a method using parameter packs. Here I implemented the sorting using the built-in KeyPathComparator:

    extension Array {
        mutating func sort<each T: Comparable>(by keyPaths: repeat (KeyPath<Element, each T>, SortOrder)) {
            var comparators: [KeyPathComparator<Element>] = []
            func addComparator<Key: Comparable>(_ keyPathOrder: (KeyPath<Element, Key>, SortOrder)) {
                comparators.append(KeyPathComparator(keyPathOrder.0, order: keyPathOrder.1))
            }
            // here I am technically creating a tuple, with its elements all being addComparator calls
            // this could be written more readably when we can iterate over a parameter pack with a for loop
            (repeat addComparator(each keyPaths))
            sort(using: comparators)
        }
    }
    

    Example Usage:

    // order by length of string ascendingly, then by the string itself descendingly
    someStrings.sort(by: (\.count, .forward), (\.self, .reverse))
    

    Pre Swift 5.9, you'll need to write a AnyComparable type eraser. This one is adapted from this post here.

    struct AnyComparable: Equatable, Comparable {
        private let lessThan: (Any) -> Bool
        private let value: Any
        private let equals: (Any) -> Bool
    
        public static func == (lhs: AnyComparable, rhs: AnyComparable) -> Bool {
            lhs.equals(rhs.value) || rhs.equals(lhs.value)
        }
        
        public init<C: Comparable>(_ value: C) {
            self.value = value
            self.equals = { $0 as? C == value }
            self.lessThan = { ($0 as? C).map { value < $0 } ?? false }
        }
    
        public static func < (lhs: AnyComparable, rhs: AnyComparable) -> Bool {
            lhs.lessThan(rhs.value) || (rhs != lhs && !rhs.lessThan(lhs.value))
        }
    }
    

    With that, you can write your sort method signature as:

    mutating func sort(by criteria: (path: KeyPath<Element, AnyComparable>, order:OrderType)...) {
        
        
    }
    

    To make it easier for us to pass key paths with the type AnyComparable in, we can make an extension:

    extension Comparable {
        // this name might be too long, but I'm sure you can come up with a better name
        var anyComparable: AnyComparable {
            .init(self)
        }
    }
    

    Now we can do:

    someArray.sort(by: (\.key1.anyComparable, .asc), (\.key2.anyComparable, .asc))