Search code examples
swiftswiftuipredicate

Implementing a composable predicate type with embedded error messages / user feedback


I have a "Swifty" version of NSPredicate that is based on a simple closure. This makes it composable but I'd like to find a way of implementing error messages to give the user feedback in the UI.

The problem arises when I attempt to compose two predicates with a logical AND - with my current implementation (which kept the predicate very simple), I can't find a meaningful way of generating an error message from the component predicates. An obvious solution would be to add a computed property to the predicate that will re-evaluate the predicate and return an error (if applicable) but that seems very inefficient.

I started to look into exposing error messages via a Combine Publisher but this got out of control quickly and seems unnecessarily complex. I've concluded that I now can't see the wood for the trees now and could do with a bit of a steer. Code base follows...

Predicate:

public struct Predicate<Target> {
    // MARK: Public roperties
    var matches: (Target) -> Bool
    var error: String

    // MARK: Init
    init(_ matcher: @escaping (Target) -> Bool, error: String = "") {
        self.matches = matcher
        self.error = error
    }

    // MARK: Factory methods
    static func required<LosslessStringComparabke: Collection>() -> Predicate<LosslessStringComparabke> {
        .init( { !$0.isEmpty }, error: "Required field")
    }

    static func characterCountMoreThan<LosslessStringComparable: Collection>(count: Int) -> Predicate<LosslessStringComparable> {
        .init({ $0.count >= count }, error: "Length must be at least \(count) characters")
    }

    static func characterCountLessThan<LosslessStringComparable: Collection>(count: Int) -> Predicate<LosslessStringComparable> {
        .init( { $0.count <= count }, error: "Length must be less than \(count) characters")
    }

    static func characterCountWithin<LosslessStringComparable: Collection>(range: Range<Int>) -> Predicate<LosslessStringComparable> {
        .init({ ($0.count >= range.lowerBound) && ($0.count <= range.upperBound) }, error: "Length must be between \(range.lowerBound) and \(range.upperBound) characters")
    }
}


// MARK: Overloads

// e.g. let uncompletedItems = list.items(matching: \.isCompleted == false)
func ==<T, V: Equatable>(lhs: KeyPath<T, V>, rhs: V) -> Predicate<T> {
    Predicate { $0[keyPath: lhs] == rhs }
}

// r.g. let uncompletedItems = list.items(matching: !\.isCompleted)
prefix func !<T>(rhs: KeyPath<T, Bool>) -> Predicate<T> {
    rhs == false
}


func ><T, V: Comparable>(lhs: KeyPath<T, V>, rhs: V) -> Predicate<T> {
    Predicate { $0[keyPath: lhs] > rhs }
}


func <<T, V: Comparable>(lhs: KeyPath<T, V>, rhs: V) -> Predicate<T> {
    //    Predicate { $0[keyPath: lhs] < rhs }
    Predicate({ $0[keyPath: lhs] < rhs }, error: "\(rhs) must be less than \(lhs)")
}


func &&<T>(lhs: Predicate<T>, rhs: Predicate<T>) -> Predicate<T> {
    return Predicate({ lhs.matches($0) && rhs.matches($0) }, error: "PLACEHOLDER: One predicate failed")
}

func ||<T>(lhs: Predicate<T>, rhs: Predicate<T>) -> Predicate<T> {
    Predicate({ lhs.matches($0) || rhs.matches($0) }, error: "PLACEHOLDER: Both predicates failed")
}

Validator (consumes predicates):

public enum ValidationError: Error, CustomStringConvertible {
    case generic(String)

    public var description: String {
        switch self {
        case .generic(let error): return error
        }
    }
}

public struct Validator<ValueType> {
    private var predicate: Predicate<ValueType>

    func validate(_ value: ValueType) -> Result<ValueType, ValidationError> {
        switch predicate.matches(value) {
        case true:
            return .success(value)
        case false:
            return .failure(.generic(predicate.error)) // TODO: placeholder
        }
    }

    init(predicate: Predicate<ValueType>) {
        self.predicate = predicate
    }
}

Validator struct is utilised by a property wrapper:

@propertyWrapper
public class ValidateAndPublishOnMain<ValueType> where ValueType: LosslessStringConvertible { // Type constraint specifically for SwiftUI text controls
   @Published private var value: ValueType

    private var validator: Validator<ValueType>

    public var wrappedValue: ValueType {
        get { value }
        set { value = newValue }
    }

    // need to also force validation to execute when the textfield loses focus
    public var projectedValue: AnyPublisher<Result<ValueType, ValidationError>, Never> {
        return $value
            .receive(on: DispatchQueue.main)
            .map { value in
                self.validator.validate(value)
        }
        .eraseToAnyPublisher()
    }

    public init(wrappedValue initialValue: ValueType, predicate: Predicate<ValueType>) {
        self.value = initialValue
        self.validator = Validator(predicate: predicate)
    }
}

...and finally, use of the property wrapper in SwiftUI (and associated view model)

public class ViewModel: ObservableObject {
    @ValidateAndPublishOnMain(predicate: .required() && .characterCountLessThan(count: 5))
    var validatedData = "" {
        willSet { objectWillChange.send() }
    }

    var errorMessage: String = ""
    private var cancellables = Set<AnyCancellable>()

    init() {
        setupBindings()
    }

    private func setupBindings() {
        $validatedData
            .map { value in
                switch value {
                case .success: return ""
                case .failure(let error): return error.description
                }
        }
        .assign(to: \.errorMessage, on: self)
        .store(in: &cancellables)
    }
}

struct ContentView: View {
    @ObservedObject var viewModel = ViewModel()
    @State private var error = ""

    var body: some View {
        VStack {
            HStack {
                Text("Label")
                TextField("Data here", text: $viewModel.validatedData)
                    .textFieldStyle(RoundedBorderTextFieldStyle())

            }.padding()

            Text("Result: \(viewModel.validatedData)")
            Text("Errors: \(viewModel.errorMessage)")
        }
        .onAppear {
            self.viewModel.objectWillChange.send() // ensures UI shows requirements immediately
        }
    }
}

Solution

  • The main reason why you have ambiguities is that the error messages are "set in stone" too early. For an && operation, you don't know the error message until the expression has been evaluated.

    Therefore, you should not store an error property. Instead, only have the error message outputted when matches returns, i.e. as its return value. Of course, you'd also need to handle the success state where there is no error message.

    Swift provides many ways to model this - you can return a String? representing the error message, or a Result<(), ValidationError>, or even a Result<Target, ValidationError>.

    And as long as you made the error message the return value of matches (whichever type you chose), you shouldn't have this ambiguity problem.

    Here, I've done it with Result<(), ValidationError>. Honestly, the code itself I quite straightforward:

    public struct ValidationError: Error {
        let message: String
    }
    
    public struct Predicate<Target> {
        var matches: (Target) -> Result<(), ValidationError>
    
        // MARK: Factory methods
        static func required<T: Collection>() -> Predicate<T> {
            .init { !$0.isEmpty ? .success(()) : .failure(ValidationError(message: "Required field")) }
        }
    
        static func characterCountMoreThan<T: StringProtocol>(count: Int) -> Predicate<T> {
            .init { $0.count > count ? .success(()) : .failure(ValidationError(message: "Length must be more than \(count) characters")) }
        }
    
        static func characterCountLessThan<T: StringProtocol>(count: Int) -> Predicate<T> {
            .init { $0.count < count ? .success(()) : .failure(ValidationError(message: "Length must be less than \(count) characters")) }
        }
    
        static func characterCountWithin<T: StringProtocol>(range: Range<Int>) -> Predicate<T> {
            .init {
                ($0.count >= range.lowerBound) && ($0.count <= range.upperBound) ?
                    .success(()) :
                    .failure(ValidationError(message: "Length must be between \(range.lowerBound) and \(range.upperBound) characters")) }
        }
    }
    
    func ==<T, V: Equatable>(lhs: KeyPath<T, V>, rhs: V) -> Predicate<T> {
        Predicate {
            $0[keyPath: lhs] == rhs ?
                .success(()) :
                .failure(ValidationError(message: "Must equal \(rhs)"))
        }
    }
    
    // r.g. let uncompletedItems = list.items(matching: !\.isCompleted)
    prefix func !<T>(rhs: KeyPath<T, Bool>) -> Predicate<T> {
        rhs == false
    }
    
    func ><T, V: Comparable>(lhs: KeyPath<T, V>, rhs: V) -> Predicate<T> {
        Predicate {
            $0[keyPath: lhs] > rhs ?
                .success(()) :
            .failure(ValidationError(message: "Must be greater than \(rhs)"))
        }
    }
    
    func <<T, V: Comparable>(lhs: KeyPath<T, V>, rhs: V) -> Predicate<T> {
        Predicate {
            $0[keyPath: lhs] < rhs ?
                .success(()) :
            .failure(ValidationError(message: "Must be less than \(rhs)"))
        }
    }
    
    
    func ||<T>(lhs: Predicate<T>, rhs: Predicate<T>) -> Predicate<T> {
        // short-circuiting version, needs a nested switch
    //    Predicate {
    //        target in
    //        switch lhs.matches(target) {
    //        case .success:
    //            return .success(())
    //        case .failure(let leftError):
    //            switch rhs.matches(target) {
    //            case .success:
    //                return .success(())
    //            case .failure(let rightError):
    //                return .failure(ValidationError(message: "\(leftError.message) AND \(rightError.message)"))
    //            }
    //        }
    //    }
    
        // without a nested switch, not short-circuiting
        Predicate {
            target in
            switch (lhs.matches(target), rhs.matches(target)) {
            case (.success, .success), (.success, .failure), (.failure, .success):
                return .success(())
            case (.failure(let leftError), .failure(let rightError)):
                return .failure(ValidationError(message: "\(leftError.message) AND \(rightError.message)"))
            }
        }
    }
    
    func &&<T>(lhs: Predicate<T>, rhs: Predicate<T>) -> Predicate<T> {
        Predicate {
            target in
            switch (lhs.matches(target), rhs.matches(target)) {
            case (.success, .success):
                return .success(())
            case (.success, let rightFail):
                return rightFail
            case (let leftFail, .success):
                return leftFail
            case (.failure(let leftError), .failure(let rightError)):
                return .failure(ValidationError(message: "\(leftError.message) AND \(rightError.message)"))
            }
        }
    }
    
    @propertyWrapper
    public class ValidateAndPublishOnMain<ValueType> where ValueType: LosslessStringConvertible { // Type constraint specifically for SwiftUI text controls
       @Published private var value: ValueType
    
        private var validator: Predicate<ValueType>
    
        public var wrappedValue: ValueType {
            get { value }
            set { value = newValue }
        }
    
        // need to also force validation to execute when the textfield loses focus
        public var projectedValue: AnyPublisher<Result<ValueType, ValidationError>, Never> {
            return $value
                .receive(on: DispatchQueue.main)
                .map { value in
                    // mapped the Result' Success type
                    self.validator.matches(value).map { _ in value }
            }
            .eraseToAnyPublisher()
        }
    
        public init(wrappedValue initialValue: ValueType, predicate: Predicate<ValueType>) {
            self.value = initialValue
            self.validator = predicate
        }
    }
    

    Note that I've changed your ValidationError to a struct rather than an enum. You could make this conform to ExpressibleByStringLiteral if you don't like the verbosity of ValidationError(message: ...).

    Another thing that you might want to consider is the messages for predicates involving key paths. Key paths don't have a human-readable string representation, so you can't have "isCompleted must equal false" as the message for \.isCompleted == false.