Better Form Model Validation
Earlier this month, I wrote about validating temporary models for forms. The validation returned .complete
or .incomplete
, which doesn’t help much when you want to show what did go wrong.
So I came up with a richer validation syntax.
You can see the code as a whole without my comments as a Gist.
Example Model, its Partial, and Validation
If this is our model:
struct User {
let firstName: String
let lastName: String
let age: Int?
}
extension User: PartialInitializable {
init(from partial: Partial<User>) throws {
self.firstName = try partial.value(for: \.firstName)
self.lastName = try partial.value(for: \.lastName)
self.age = partial.value(for: \.age)
}
}
Then I want validations to look like this:
// Specify a non-empty list of validation requirements
let validation = Partial<User>.Validation(
.required(\User.firstName),
.valueValidation(keyPath: \User.firstName, { !$0.isEmpty })
.required(\User.lastName),
.valueValidation(keyPath: \User.lastName, { $0.count > 5 }),
.valueValidation(keyPath: \User.age, { $0 >= 18 })
Given this validation and a “partial” which contains data provided by the user, executing and evaluating the validation will look like this:
var partial = Partial<User>()
partial.update(\.firstName, to: "foo")
partial.update(\.lastName, to: "bar")
partial.update(\.age, to: 12) // <- too young!
switch validation.validate(partial) {
case .valid(let user):
print("Is valid: \(user)")
case .invalid(let reasons):
for reason in reasons {
switch reason {
case .missing(let keyPath):
if keyPath == \User.firstName { print("Missing first name") }
if keyPath == \User.lastName { print("Missing last name") }
case .invalidValue(let keyPath):
if keyPath == \User.firstName { print("Invalid first name value") }
if keyPath == \User.lastName { print("Invalid last name value") }
if keyPath == \User.age { print("User is too young") }
}
}
}
Thanks to nested generic types inside generic types, this is pretty easy to accomplish!
The Code, Explained in Much Detail
protocol PartialInitializable {
init(from partial: Partial<Self>) throws
}
This is new: it is a type restriction so that successful Partial<T>.Validation
can attempt to create an instance right away.
Except for the T: PartialInitializable
constraint, this is pretty much Ian’s original Partial<T>
:
struct Partial<T> where T: PartialInitializable {
enum Error: Swift.Error {
case valueNotFound
}
private var data: [PartialKeyPath<T>: Any] = [:]
mutating func update<U>(_ keyPath: KeyPath<T, U>, to newValue: U?) {
data[keyPath] = newValue
}
func value<U>(for keyPath: KeyPath<T, U>) throws -> U {
guard let value = data[keyPath] as? U else { throw Error.valueNotFound }
return value
}
func value<U>(for keyPath: KeyPath<T, U?>) -> U? {
return data[keyPath] as? U
}
}
Here comes the validation extension:
extension Partial {
struct Validation {
enum Strategy {
case required(PartialKeyPath<T>)
case value(AnyValueValidation)
static func valueValidation<V>(keyPath: KeyPath<T, V>, _ block: @escaping (V) -> Bool) -> Strategy {
let validation = ValueValidation(keyPath: keyPath, block)
return .value(AnyValueValidation(validation))
}
struct AnyValueValidation {
let keyPath: PartialKeyPath<T>
private let _isValid: (Any) -> Bool
init<V>(_ base: ValueValidation<V>) {
keyPath = base.keyPath
_isValid = {
guard let value = $0 as? V else { return false }
return base.isValid(value)
}
}
func isValid(partial: Partial<T>) -> Bool {
guard let value = partial.data[keyPath] else { return false }
return _isValid(value)
}
}
struct ValueValidation<V> {
let keyPath: KeyPath<T, V>
let isValid: (V) -> Bool
init(keyPath: KeyPath<T, V>, _ isValid: @escaping (V) -> Bool) {
self.keyPath = keyPath
self.isValid = isValid
}
}
}
Partial<T>.Validation.Strategy
offers two modes to validate key paths at the moment:
required
, which just checks for mere presence of any value, andvalue
, which performs a custom check via a closure. Use this to validate that a string is non-empty, for example.
The ValueValidation<V>
type accepts a key path of type KeyPath<T,V>
. Part of the key path’s generic constraints are satisfied by Partial<T>
, so the subject is given; the value you point to specifies which type ValueValidation
will work on.
Example: The key path \User.name
has the type KeyPath<User, String>
. If you pass this in, you’ll get a Partial<User>.Validation.ValueValidation<String>
. The second parameter in its initializer is the actual boolean validity check. Thanks to the power of generics and nested types within generic types, we end up with a very specialized ValueValidation
. The compiler will help us with these in place: we cannot accidentally treat string values as numbers, unlike a dictionary of type [String : Any]
, where the cast from Any
may fail for all the wrong reasons.
While creating ValueValidation
objects with a key path and a fitting closure is then very straight-forward, you cannot lump different value type validations together. [ValueValidation<Int>(...), ValueValidation<String>(...)]
will be an Array<Any>
since the specified types have nothing in common although you and I know they’re supposed to do a similar thing. The same-ness has to be expressed in code, though. In other words, we have to erase the generic information and thus make all generic specializations the same. There’s no communism(_:)
function in the Swift standard library that does this for us. We need to provide another type on our own and type-erased the generic V
from the ValueValidation<V>
in order to do that. I went with the AnyValueValidation
approach that hides the generic constraint from the outside and wraps the isValid
check accordingly.
That’s why the Strategy case is called value(AnyValueValidation)
and not value(ValueValidation)
– because the latter won’t compile.
Initializing the type-erased validation sucks big time, though: AnyValueValidation(ValueValidation(keyPath: \User.name, { !$0.isEmpty }))
.
That’s why I added a static factory called valueValidation
. When you call it, it looks like an enum case, but really isn’t. You’ll see how we can call it as ` .valueValidation(keyPath: \User.firstName, { $0.count > 5 }))` in a second.
Now that the Strategy
type is declared, here’s how it’s used when computing a Partial<T>.Validation.Result
:
let validations: [Strategy]
// Prevent creating an empty validation collection by making 1 parameter
// required, and then add a variadic list afterwards.
init(_ first: Strategy, _ rest: Strategy...) {
var all = [first]
all.append(contentsOf: rest)
self.validations = all
}
enum Result {
case valid(T)
case invalid([Reason])
enum Reason {
case missing(PartialKeyPath<T>)
case invalidValue(PartialKeyPath<T>)
}
}
func validate(_ partial: Partial<T>) -> Result {
var failureReasons: [Result.Reason] = []
for validation in validations {
switch validation {
case .required(let keyPath):
if !partial.data.keys.contains(keyPath) {
failureReasons.append(.missing(keyPath))
}
case .value(let valueValidation):
if !valueValidation.isValid(partial: partial) {
failureReasons.append(.invalidValue(valueValidation.keyPath))
}
}
}
guard failureReasons.isEmpty else { return .invalid(failureReasons) }
return .valid(try! T.init(from: partial))
}
}
}
The validate
method exercises all instances of the validation strategy. The failure reasons match the strategy cases: you get missing
for required
, and invalidValue
for value
. Both pass the PartialKeyPath<T>
along.
It’d be nice to have the fully qualified KeyPath<T, V>
here, but again you cannot lump these together. PartialKeyPath<T>
is a type-erased variant already – but using a different approach, namely being the parent class instead of boxing the specialized type in.
After validation, you get a Result
; if nothing failed, you will get a fully initialized object. Hopefully. The initializer could throw an error for reasons not expressed in the validation constraints. That’d be bad. And I’d argue that the person responsible for throwing an error that’s not covered by the validation mechanism didn’t adhere to the contract of PartialInitializable
.
Improvements
There’s room for improvement. For example, I think I’ll rename Strategy
to Constraint
and then provide the constraint’s user-facing message upon initialization. That way you can have multiple value validations for the same property like “age is above 18” and “age is below 30” for cheap student insurance rates in Germany with different explanations for the validation failure.
Also, I don’t like the try!
a lot. I still think it’s a programmer error if a validation passes but object initialization isn’t possible, because that’s the whole point of all this. But there could be a better way.
Maybe there’s room for more Constraints
? The value validation is very powerful already, but maybe it’s too generic and someone could use more specialized cases instead.
Again, if you want to have a look at the whole code, it’s available as a Gist on GitHub. Feedback is very welcome!