KISS

Keep It Simple Stupid

Result builder example for validation in swift

| comments

My recent (2020?!) post introduced an idea for lightweight validation in swift that can gather all the errors that failed the validation.

This post is a continuation where I implement a result builder for this validation so that it would look a bit cleaner:

1
2
3
4
all {
    foo == bar <?> "Incompatible operations"
    baz == 42 <?> "Unexpected baz"
}

swift 5.4

Result builders are a new feature in swift 5.4 (which is in Xcode 12.5 by default). Xcode 12.4 doesn’t have it, so if you use it, you can download the official swift 5.4 compiler from https://swift.org/download/: https://swift.org/builds/swift-5.4.1-release/xcode/swift-5.4.1-RELEASE/swift-5.4.1-RELEASE-osx.pkg. However the built tests don’t run on OSX 10.14:

1
xctest encountered an error (Could not launch LightweightValidationTests. internal error)

Internal error, you see.

Implementation

The implementation is available in the feature/result_builders branch of the LightweightValidation repository. Tests are also added in every commit.

A result builder is a struct with the @resultBuilder attribute that implements one or more of the required functions for different syntax cases. The first version only contains buildBlock(_:...) that’s used to build a result from 1+ validations:

1
2
3
4
5
6
@resultBuilder
public struct ConjunctionBuilder {
    public static func buildBlock <T, E> (_ validations: V<T, E>...) -> V<T, E> {
        validations[0]
    }
}

This is the simplest case where we return the first validation (a new test verifies that). To use this builder you need to have a function that accepts a closure containing the validation steps:

1
2
3
4
5
public extension V {
    static func all(@ConjunctionBuilder _ validations: () -> V<T, E>) -> V<T, E> {
        validations()
    }
}

As a reminder, V is the validation type we’re working with from the first blogpost. This all function is the API for our users:

1
2
3
let sut = Response.SimpleValidationResult.all {
    true <?> StringError("never happens")
}

The next logical step is to finish the implementation so that we can actually use:

1
2
3
4
5
V.all {
  validateUserName
  validateUserEmail
  validateUserPassword
}

in addition to:

1
validateUserName && validateUserEmail && validateUserPassword

As I understand, validations can’t be empty in that buildBlock because there is another signature for the case when there is nothing in the result builder and I haven’t implemented that one, so the implementation was:

1
2
3
4
5
public static func buildBlock <E> (_ validations: V<(), E>...) -> V<(), E> {
    validations.dropFirst().reduce(validations[0]) { result, validation in
        result && validation
    }
}

which I changed to a safer one:

1
2
3
4
5
public static func buildBlock <E> (_ validations: V<(), E>...) -> V<(), E> {
    validations.reduce(.empty) { result, validation in
        result && validation
    }
}

This is where we && all the supplied validations, returning the same result as if we manually &&‘ed them. V.empty = V.value(()) is an identity that can be combined with any validation and not change it.

The next functions I added are to support ifs without and with else:

1
2
3
4
5
6
7
8
9
10
11
public static func buildOptional <E> (_ validation: V<(), E>?) -> V<(), E> {
    validation ?? .empty
}

public static func buildEither <E> (first validation: V<(), E>) -> V<(), E> {
    validation
}

public static func buildEither <E> (second validation: V<(), E>) -> V<(), E> {
    validation
}

And now we can update Response.validateUserName to this:

1
2
3
4
5
6
7
8
9
/// Usernames are minimum 3 chars long and cannot include `@`.
private func validateUserName() -> SimpleValidationResult {
    // for some reason, the strings can't be automatically converted to `StringError` here
    // `Cannot convert value of type 'V<(), String>' to closure result type 'V<(), StringError>'`
    SimpleValidationResult.all {
        userName.count >= 3 <?> StringError("Username \(userName) must be 3+ chars")
        !userName.contains("@") <?> StringError("Username \(userName) must not contain '@'")
    }
}

Result

How much cleaner is the resulting syntax? With the simple example, not much honestly. But since you can also support conditions and loops right in the closures, it probably becomes more straightforward then.

To learn more:

I still can’t shake the feeling that adding these syntax sugars to the compiler is a limiting approach because no library can do the same. For example, throws added error reporting to functions, but it’s built into language and has shortcomings vs creating a Result (aka Either) that can be done by any library w/o touching the compiler.

Comments