I recently saw a post on how to compose different validators together. I thought it was a good start, but I think it didn’t go quite far enough, especially using the fact that Rob Napier has so kindly reminded us about Swift: types are the unit of composition in Swift.
Here’s the basic setup: there is a need to create some basic validation for email and passwords (the correctness of the validation rules is not important to the discussion).
Scott modeled out what how he conceptually thought of each, which I thought was good. I’ve put those models here, with only some minor name changes:
Email Validation
┌────────────────────────┐
│ email validation │
└────────────────────────┘
▲
┌───────────────┴───────────────┐
┌────────────────────────┐ ┌────────────────────────┐
│ empty │ │ invalid format │
└────────────────────────┘ └────────────────────────┘
Password Validation
┌────────────────────────┐
│ password validation │
└────────────────────────┘
▲
┌───────────────┴───────────────┐
┌────────────────────────┐ ┌────────────────────────┐
│ empty │ │ weak │
└────────────────────────┘ └────────────────────────┘
▲
│ ┌────────────────────────┐
├─│ length │
│ └────────────────────────┘
│ ┌────────────────────────┐
├─│ missing uppercase │
│ └────────────────────────┘
│ ┌────────────────────────┐
├─│ missing lowercase │
│ └────────────────────────┘
│ ┌────────────────────────┐
└─│ missing number │
└────────────────────────┘
I think these are conceptually good. However, I wasn’t a fan of how Scott transcribed these models over to their enum representations.
Scott’s Version
enum EmailValidatorError: Error {
case empty
case invalidFormat
}
enum PasswordValidatorError: Error {
case empty
case tooShort
case noUppercaseLetter
case noLowercaseLetter
case noNumber
}
I think the PasswordValidationError
missed the mark as he flattened out the tree.
My Version
enum EmailValidationError: Error {
case empty
case invalidFormat
}
enum PasswordValidationError: Error {
case empty
case weak(reasoning: [PasswordStrengthValidationError])
}
enum PasswordStrengthValidationError: Error {
case length
case missingUppercase
case missingLowercase
case missingNumber
}
I break out the weak
items into their own PasswordStrengthValidationError
error enum to match the conceptual model. This has the benefit of provide a high-level classification of “weak password” while still maintaining the rigor of getting the specific details about why the password is considered weak.
Validation
Next up is the modeling of the validator itself. I like that Scott chose to model this as a protocol. After all, we do know that we are going to need two different types of protocols:
- Single validation
- Composite validation
However, unlike Scott, I would have modeled both of these with a protocol, like so:
protocol Validator {
func validate(_ value: String) -> Result
}
protocol CompositeValidator: Validator {
var validators: [Validator] { get }
func validate(_ value: String) -> [Result]
}
Side note: I’m also use a more generic
Result
type vs. Scott’sValidationResult
. It’s simply defined as:enum Result { case ok(ValueType) case error(Error) }
The important thing to note about the CompositeValidator
is that it supports returning both a single result and a listing of all of the results. The other nice thing about this is that it’s also possible to provide a default implementation for all implementors of this protocol.
extension CompositeValidator {
func validate(_ value: String) -> [Result] {
return validators.map { $0.validate(value) }
}
func validate(_ value: String) -> Result {
let results: [Result] = validate(value)
let errors = results.filter {
if case .error(_) = $0 {
return true
}
else {
return false
}
}
return errors.first ?? .ok(value)
}
}
In the single result case, we simply return the first error
value, if present, or return the ok
value. This default implementation will come in really handy when we start implementing things later on.
Types as the Foundation
Remember that types are the foundation? So yeah… let’s start implementing the email validators as they are the most straight-forward of the two.
class EmailEmptyValidator: Validator {
func validate(_ value: String) -> Result {
return value.isEmpty ? .error(EmailValidationError.empty) : .ok(value)
}
}
class EmailFormatValidator: Validator {
func validate(_ value: String) -> Result {
let magicEmailRegexStolenFromTheInternet = "^[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$"
let emailTest = NSPredicate(format:"SELF MATCHES %@", magicEmailRegexStolenFromTheInternet)
return emailTest.evaluate(with: value) ?
.ok(value) :
.error(EmailValidationError.invalidFormat)
}
}
The single value ones are fairly straight forward – you just implement the logic in the validate
function based on the value
passed in.
Now, to compose the email validations, all we need to do is this:
class EmailValidator: CompositeValidator {
let validators: [Validator]
init() {
self.validators = [
EmailEmptyValidator(),
EmailFormatValidator()
]
}
}
That’s it! No extra code to worry about as the default CompositeValidator
extensions handle this correctly.
The usage code looks like this:
let emailValidator = EmailValidator()
validate(value: "", validator: emailValidator)
validate(value: "invalidEmail@", validator: emailValidator)
validate(value: "validEmail@validDomain.com", validator: emailValidator)
Which will output:
value: "" => error(EmailValidationError.empty)
value: "invalidEmail@" => error(EmailValidationError.invalidFormat)
value: "validEmail@validDomain.com" => ok("validEmail@validDomain.com")
Password Validation
The single value password validators are all straight forward to implement:
class PasswordEmptyValidator: Validator {
func validate(_ value: String) -> Result {
return value.isEmpty ? .error(PasswordValidationError.empty) : .ok(value)
}
}
class PasswordLengthValidator: Validator {
static let minimumPasswordLength: Int = 8
func validate(_ value: String) -> Result {
return value.characters.count >= PasswordLengthValidator.minimumPasswordLength ?
.ok(value) :
.error(PasswordStrengthValidationError.length)
}
}
class PasswordIncludesUppercaseValidator: Validator {
func validate(_ value: String) -> Result {
return value.rangeOfCharacter(from: NSCharacterSet.uppercaseLetters) != nil ?
.ok(value) :
.error(PasswordStrengthValidationError.missingUppercase)
}
}
class PasswordIncludesLowercaseValidator: Validator {
func validate(_ value: String) -> Result {
return value.rangeOfCharacter(from: NSCharacterSet.lowercaseLetters) != nil ?
.ok(value) :
.error(PasswordStrengthValidationError.missingLowercase)
}
}
class PasswordIncludesNumbersValidator: Validator {
func validate(_ value: String) -> Result {
return value.rangeOfCharacter(from: NSCharacterSet.decimalDigits) != nil ?
.ok(value) :
.error(PasswordStrengthValidationError.missingNumber)
}
}
There really is nothing special going on here. However, what does the validation for the weak
error type look like? Well, here it is:
class PasswordStrengthValidator: CompositeValidator {
let validators: [Validator]
init() {
self.validators = [
PasswordLengthValidator(),
PasswordIncludesUppercaseValidator(),
PasswordIncludesLowercaseValidator(),
PasswordIncludesNumbersValidator()
]
}
func validate(_ value: String) -> Result {
let result = validate(value) as [Result]
let errors = result.filter { if case .error(_) = $0 { return true }; return false }
if errors.isEmpty { return .ok(value) }
let reasons: [PasswordStrengthValidationError] = errors.map {
if case let .error(reason) = $0 { return reason as! PasswordStrengthValidationError }
fatalError("This code should never be reached. It is an error if it ever hits.")
}
return .error(PasswordValidationError.weak(reasoning: reasons))
}
}
As you can see, it is necessary to change the default implementation of validate
as we would like to do a custom conversion to box the potential list of failures reasons into a single weak
value. If we modeled the error type as Scott had them, this step would be unnecessary.
Finally, we look at the all up composite validator for passwords:
class PasswordValidator: CompositeValidator {
let validators: [Validator]
init() {
self.validators = [
PasswordEmptyValidator(),
PasswordStrengthValidator()
]
}
}
Again, we can simply use the default implementations for our two validate
functions as there is no special logic we need here. The usage code is:
let passwordValidator = PasswordValidator()
validate(value: "", validator: passwordValidator)
validate(value: "psS$", validator: passwordValidator)
validate(value: "passw0rd", validator: passwordValidator)
validate(value: "paSSw0rd", validator: passwordValidator)
With the output of:
value: "" => error(PasswordValidationError.empty)
value: "psS$" => error(PasswordValidationError.weak([PasswordStrengthValidationError.length, PasswordStrengthValidationError.missingNumber]))
value: "passw0rd" => error(PasswordValidationError.weak([PasswordStrengthValidationError.missingUppercase]))
value: "paSSw0rd" => ok("paSSw0rd")
In the End
This is really just a refinement of Scott’s approach. I think he started off with the right thought process. The biggest critique I have about his approach is in the end when he needed to use functions to encapsulate the construction the various composite validators. Using functions as constructors can often times mean you simply don’t have the right type abstraction yet. I do believe that is the case here, which is why I introduced the CompositeValidator
protocol.
Update: Here’s a link to the full playground source code: https://gist.github.com/owensd/6a99ca908d3c13c8b19c9a42aaf3cd9d.
You must be logged in to post a comment.