Search code examples
swiftprotocolsextension-methodsrawrepresentablecustomstringconvertible

Is there a way to simplify this 'matrix of overloads' based on argument types which are all ultimately representable by a specific type?


We're trying to create a function addQueryItem which ultimately uses a string and an optional string internally.

For more flexibility in the API, rather than use String for the argument types, we are instead using CustomStringConvertible (which String implements) so we can use anything that can be represented as a string.

Additionally, so we can pass it String-based enums, we also want it to accept RawRepresentable types where RawValue is a CustomStringConvertible itself.

However, since we're now technically accepting two different kinds of values for each parameter, we end up having to create a 'matrix of overloads'--four total--for each combination of the two types.

My first thought was to use protocol-oriented programming by extending RawRepresentable so it adheres to CustomStringConvertible if its RawValue was also a CustomStringConvertible. Then I could just pass that directly to the version which takes two CustomStringConvertible arguments and eliminate the other three. However, the compiler didn't like it because I'm trying to extend a protocol, not a concrete type.

// This doesn't work
extension RawRepresentable : CustomStringConvertible
where RawValue:CustomStringConvertible {

    var description: String {
        return self.rawValue
    }
}

As a result of not being able to do the above, as mentioned, I have to have all four of the following:

func addQueryItem(name:CustomStringConvertible, value:CustomStringConvertible?){

    if let valueAsString = value.flatMap({ String(describing:$0) }) {
        queryItems.append(name: String(describing:name), value: valueAsString)
    }
}

func addQueryItem<TName:RawRepresentable>(name:TName, value:CustomStringConvertible?)
where TName.RawValue:CustomStringConvertible {
    addQueryItem(name: name.rawValue, value: value)
}

func addQueryItem<TValue:RawRepresentable>(name:CustomStringConvertible, value:TValue?)
where TValue.RawValue:CustomStringConvertible {

    addQueryItem(name: name, value: value?.rawValue)
}

func addQueryItem<TName:RawRepresentable, TValue:RawRepresentable>(name:TName, value:TValue?)
where TName.RawValue:CustomStringConvertible,
      TValue.RawValue:CustomStringConvertible
{
    addQueryItem(name: name.rawValue, value: value?.rawValue)
}

So, since it doesn't look like it's possible to make RawRepresentable to adhere to CustomStringConvertible, is there any other way to solve this 'matrix-of-overloads' issue?


Solution

  • To expand on my comments, I believe you're fighting the Swift type system. In Swift you generally should not try to auto-convert types. Callers should explicitly conform their types when they want a feature. So to your example of an Order enum, I believe it should be implemented this way:

    First, have a protocol for names and values:

    protocol QueryName {
        var queryName: String { get }
    }
    
    protocol QueryValue {
        var queryValue: String { get }
    }
    

    Now for string-convertible enums, it's nice to not have to implement this yourself.

    extension QueryName where Self: RawRepresentable, Self.RawValue == String  {
        var queryName: String { return self.rawValue }
    }
    
    extension QueryValue where Self: RawRepresentable, Self.RawValue == String  {
        var queryValue: String { return self.rawValue }
    }
    

    But, for type-safety, you need to explicitly conform to the protocol. This way you don't collide with things that didn't mean to be used this way.

    enum Order: String, RawRepresentable, QueryName {
        case buy
    }
    
    enum Item: String, RawRepresentable, QueryValue {
        case widget
    }
    

    Now maybe QueryItems really has to take strings. OK.

    class QueryItems {
        func append(name: String, value: String) {}
    }
    

    But the thing that wraps this can be type-safe. That way Order.buy and Purchase.buy don't collide (because they can't both be passed):

    class QueryBuilder<Name: QueryName, Value: QueryValue> {
        var queryItems = QueryItems()
    
        func addQueryItem(name: QueryName, value: QueryValue?) {
            if let value = value {
                queryItems.append(name: name.queryName, value: value.queryValue)
            }
        }
    }
    

    You can use the above to make it less type-safe (using things like StringCustomConvertible and making QueryBuilder non-generic, which I do not recommend, but you can do it). But I would still strongly recommend that you have callers explicitly tag the types they plan to use this way by explicitly labelling (and nothing else) that they conform to the protocol.


    To show what the less-safe version would look like:

    protocol QueryName {
        var queryName: String { get }
    }
    
    protocol QueryValue {
        var queryValue: String { get }
    }
    
    extension QueryName where Self: RawRepresentable, Self.RawValue == String  {
        var queryName: String { return self.rawValue }
    }
    
    extension QueryValue where Self: RawRepresentable, Self.RawValue == String  {
        var queryValue: String { return self.rawValue }
    }
    
    extension QueryName where Self: CustomStringConvertible {
        var queryName: String { return self.description }
    }
    
    extension QueryValue where Self: CustomStringConvertible {
        var queryValue: String { return self.description }
    }
    
    
    class QueryItems {
        func append(name: String, value: String) {}
    }
    
    class QueryBuilder {
        var queryItems = QueryItems()
    
        func addQueryItem<Name: QueryName, Value: QueryValue>(name: Name, value: Value?) {
            if let value = value {
                queryItems.append(name: name.queryName, value: value.queryValue)
            }
        }
    }
    
    enum Order: String, RawRepresentable, QueryName {
        case buy
    }
    
    enum Item: String, RawRepresentable, QueryValue {
        case widget
    }