Error Management and Data Validation with
Bow
Tomás Ruiz-López
Senior Software Engineer at 47degrees
@tomasruizlopez
## Outline
1. Running example
2. Error modeling
3. Optionals
4. Result
5. Fail-fast
6. Error accumulation
7. Applicative
8. Conclusions
## Running example
Validate user input to create a form.
- First and last name must not be empty.
- Age must be over 18.
- Document ID must be 8 digits followed by a letter.
- Phone number must have 9 digits.
- Email must contain an @ symbol.
## Error handling
![Swift book cover](img/swift_programming_language.jpg)
### Error modeling
```swift
enum ValidationError: Error {
case emptyFirstName(String)
case emptyLastName(String)
case userTooYoung(Date)
case invalidDocumentId(String)
case invalidPhoneNumber(String)
case invalidEmail(String)
}
```
### Success modeling
```swift
public struct Form {
let firstName: String
let lastName: String
let birthday: Date
let documentId: String
let phoneNumber: String
let email: String
}
```
### Validation functions
```swift
func validate(firstName: String) throws -> String {
guard !firstName.trimmingCharacters(in:
.whitespacesAndNewlines).isEmpty else {
throw ValidationError.emptyFirstName(firstName)
}
return firstName
}
```
### Validation functions
```swift
func validate(firstName: String) throws -> String
```
### Using Optionals
Modeling errors as absent values
```swift
func validate(firstName: String) -> String? {
guard !firstName.trimmingCharacters(in:
.whitespacesAndNewlines).isEmpty else {
return nil
}
return firstName
}
```
## Encode information in types
Use the compiler in your advantage
### Using Result
```swift
let result = Result(catching: {
try validate(firstName: "")
})
```
Problem:
```swift
result: Result<String, Error>
```
### Using Result
```swift
func validate(email: String)
-> Result<String, ValidationError> {
if email.contains("@") {
return .success(email)
} else {
return .failure(.invalidEmail(email))
}
}
```
### Using Result
```swift
func nonEmpty(_ string: String,
orElse: (String) -> ValidationError)
-> Result<String, ValidationError> {
if !string.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
return .success(string)
} else {
return .failure(orElse(string))
}
}
```
### Using Result
```swift
func validate(firstName: String)
-> Result<String, ValidationError> {
return nonEmpty(firstName,
orElse: ValidationError.emptyFirstName)
}
func validate(lastName: String)
-> Result<String, ValidationError> {
return nonEmpty(lastName,
orElse: ValidationError.emptyLastName)
}
```
### Using Result
```swift
func validate(birthday: Date,
referenceDate: Date)
-> Result<Date, ValidationError> {
if Calendar.current.date(byAdding: .year,
value: 18,
to: birthday)! < referenceDate {
return .success(birthday)
} else {
return .failure(.userTooYoung(birthday))
}
}
```
### Using Result
```swift
func matches(_ string: String,
regExp: NSRegularExpression,
orElse: (String) -> ValidationError)
-> Result<String, ValidationError> {
if regExp.firstMatch(in: string,
options: [],
range: NSRange(location:0,
length: string.count))
!= nil {
return .success(string)
} else {
return .failure(orElse(string))
}
}
```
### Using Result
```swift
func validate(documentId: String) -> Result {
let documentRegEx = try! NSRegularExpression(pattern:
"\\d{8}[a-zA-Z]{1}")
return matches(documentId,
regExp: documentRegEx,
orElse: ValidationError.invalidDocumentId)
}
func validate(phoneNumber: String) -> Result {
let phoneRegEx = try! NSRegularExpression(pattern:
"\\d{9}")
return matches(phoneNumber,
regExp: phoneRegEx,
orElse: ValidationError.invalidPhoneNumber)
}
```
### Combining Results
```swift
let firstNameResult = validate(firstName: "Tomás")
let lastNameResult = validate(lastName: "Ruiz-López")
let birthdayResult = validate(birthday: myBirthday)
let documentIdResult = validate(documentId: "00000000A")
let phoneResult = validate(phoneNumber: "666111222")
let emailResult = validate(email: "tomas.ruiz@47deg.com")
let form = ???
```
### Combining Results
```swift
let firstNameResult = validate(firstName: "Tomás")
switch firstNameResult {
case let .success(name): ...
case let .failure(error): ...
}
firstNameResult.map { name in ... }
```
### Combining Results
```swift
switch (firstNameResult, lastNameResult) {
case let (.success(firstName), .success(lastName)): ...
case let (.failure(error), _): ...
case let (_, .failure(error)): ...
}
// API not available
(firstNameResult, lastNameResult).map
{ firstName, lastName in ... }
```
### Combining Results
We can follow two different combination strategies:
1. **Fail-fast**: return the first error found.
2. **Error accumulation**: provide a list of all errors found.
### Either data type
Represents the sum of two types.
- `Either.left` ~ `Result.failure`
- `Either.right` ~ `Result.success`
### Either data type
Result can be easily transformed to Either and leverage its API:
```swift
let firstNameResult: Result<String, VerificationError> =
validate(firstName: "Tomás")
let firstNameEither: Either<VerificationError, String> =
firstNameResult.toEither()
let backToResult = firstNameEither.toResult()
```
### Either - Fail fast
```swift
func failFast(firstName: String,
lastName: String,
birthday: Date,
documentId: String,
phoneNumber: String,
email: String) -> Either {
return Either<ValidationError, Form>.map(
validate(firstName: firstName).toEither(),
validate(lastName: lastName).toEither(),
validate(birthday: birthday, referenceDate: Date()).toEither(),
validate(documentId: documentId).toEither(),
validate(phoneNumber: phoneNumber).toEither(),
validate(email: email).toEither(),
Form.init)^
}
```
### Either - Fail fast
If all inputs are correct, `Form.init` is invoked with the corresponding values:
```swift
failFast(firstName: "Tomás",
lastName: "Ruiz-López",
birthday: Date(timeIntervalSince1970: 10),
documentId: "12345678T",
phoneNumber: "666111222",
email: "tomas.ruiz@47deg.com")
// Right(Form(firstName: "Tomás", lastName: "Ruiz-López",
// birthday: 1970-01-01 00:00:10 +0000,
// documentId: "12345678T",
// phoneNumber: "666111222",
// email: "tomas.ruiz@47deg.com"))
```
### Either - Fail fast
If any input is incorrect, the first error found is returned:
```swift
failFast(firstName: "",
lastName: "",
birthday: Date(),
documentId: "1B",
phoneNumber: "AABBCC",
email: "myemail")
// Left(First name is empty: "")
```
### Validated data type
A data type to represent valid and invalid values.
- `Validated.valid` ~ `Result.success`
- `Validated.invalid` ~ `Result.failure`
### Validated data type
Result can easily be transformed to Validated and leverage its API:
```swift
let firstNameResult: Result<String, ValidationError> =
validate(firstName: "Tomás")
let firstNameValidated: Validated<ValidationError, String> =
firstNameResult.toValidated()
```
### Validated - Error accumulation
It models having a single error:
```swift
Validated<ValidationError, Form>
```
### Validated - Error accumulation
We need to accumulate all errors in the validation process:
```swift
Validated<[ValidationError], Form>
```
Problem:
```swift
let illegal = Validated<[ValidationError], Form>.invalid([])
```
# Make illegal states impossible to represent
Again, use the compiler in your advantage
### Validated - Error accumulation
Accumulates errors and guarantees there is at least one:
```swift
Validated<NonEmptyArray<ValidationError>, Form>
// Or alternatively:
Validated<NEA<ValidationError>, Form>
// Even more succint, with the ValidatedNEA typealias:
ValidatedNEA<ValidationError, Form>
```
### Validated data type
Result can easily be transformed to ValidatedNEA and leverage its API:
```swift
let firstNameResult: Result<String, ValidationError> =
validate(firstName: "Tomás")
let firstNameValidated: ValidatedNEA<ValidationError, String> =
firstNameResult.toValidatedNEA()
```
### Validated - Error accumulation
```swift
func errorAccummulation(firstName: String,
lastName: String,
birthday: Date,
documentId: String,
phoneNumber: String,
email: String) -> ValidatedNEA<ValidationError, Form> {
return ValidatedNEA<ValidationError, Form>.map(
validate(firstName: firstName).toValidatedNEA(),
validate(lastName: lastName).toValidatedNEA(),
validate(birthday: birthday, referenceDate: Date()).toValidatedNEA(),
validate(documentId: documentId).toValidatedNEA(),
validate(phoneNumber: phoneNumber).toValidatedNEA(),
validate(email: email).toValidatedNEA(),
Form.init)^
}
```
### Validated - Error accumulation
If all inputs are correct, `Form.init` is invoked with the corresponding values:
```swift
errorAccummulation(firstName: "Tomás",
lastName: "Ruiz-López",
birthday: Date(timeIntervalSince1970: 10),
documentId: "12345678T",
phoneNumber: "666111222",
email: "tomas.ruiz@47deg.com")
// Valid(Form(firstName: "Tomás", lastName: "Ruiz-López",
// birthday: 1970-01-01 00:00:10 +0000,
// documentId: "12345678T",
// phoneNumber: "666111222",
// email: "tomas.ruiz@47deg.com"))
```
### Validated - Error accumulation
If some inputs are invalid, they are combined and reported:
```swift
errorAccummulation(firstName: "",
lastName: "",
birthday: Date(),
documentId: "1B",
phoneNumber: "AABBCC",
email: "myemail")
// Invalid(NonEmptyArray([User's email is invalid: "myemail",
// User's phone number is invalid: "AABBCC",
// User's document id is invalid: "1B",
// User is too young: 2019-05-27 13:46:31 +0000,
// Last name is empty: "", First name is empty: ""]))
```
### Summary
| Data type | Extract values | Combine values | Combination strategy |
| --------- |:--------------:|:--------------:|:--------------------:|
| `Result<Success, Failure>` | Pattern matching | 😔 | 🤷🏻♂️ |
| `Either<Failure, Success>` | `fold` | `map` | Fail-fast |
| `Validated<Failure, Success>` | `fold` | `map` | Error accumulation |
## Applicative
With simulated Higher-Kinded Types:
```swift
static func ap<A, B>(_ ff: Kind<F, (A) -> B>,
_ fa: Kind<F, A>) -> Kind<F, B>
```
Someday, with native Higher-Kinded Types...
```swift
static func ap<A, B>(_ ff: F<(A) -> B>,
_ fa: F<A>) -> F<B>
```
### Applicative
Perform independent computations and combine their results.
```swift
Either<ValidationError, Form>.map(
fa, fb, fc) { a, b, c in ... }
Validated<ValidationError, Form>.map(
fa, fb, fc) { a, b, c in ... }
// In general, working with any type F
F<Form>.map(
fa, fb, fc) { a, b, c in ... }
```
## Lessons learned
- Use **types** to model success and error cases.
- Result is nice, but needs a **more powerful API**.
- Bow provides **Either** (*fail-fast*) and **Validated** (*error accumulation*).
- Make illegal states **impossible to represent**.
- Use **abstractions** based on type classes.
Bonus Track I
I don't care about the type that I'm using
a.k.a.
Tagless Final
### Tagless Final
`F: ApplicativeError`
- `F.pure ~ Result.success`
- `F.raiseError ~ Result.failure`
### Tagless Final
Instances of `ApplicativeError`
| Data Type | `pure` | `raiseError` |
| --------- |:------:|:------------:|
| `Either` | `right` | `left` |
| `Validated` | `valid` | `invalid` |
### Tagless Final
Creating success and failure
```swift
class ValidationRules<F: ApplicativeError>
where F.E == NEA<ValidationError> {
static func validate(email: String) -> Kind<F, String> {
if email.contains("@") {
return .pure(email)
} else {
return .raiseError(.of(.invalidEmail(email)))
}
}
}
```
### Tagless Final
Combining independent computations
```swift
class ValidationRules<F: ApplicativeError> {
static func makeForm(firstName: String, lastName: String,
birthday: Date,
documentId: String,
phoneNumber: String,
email: String) -> Kind<F, Form> {
return F.map(validate(firstName: firstName),
validate(lastName: lastName),
validate(birthday: birthday, referenceDate: Date()),
validate(documentId: documentId),
validate(phoneNumber: phoneNumber),
validate(email: email),
Form.init)
}
}
```
### Tagless final
Interpretation to `Either`
```swift
ValidationRules<EitherPartial<NEA<ValidationError>>>
.makeForm(
firstName: "Tomás",
lastName: "Ruiz-López",
birthday: Date(timeIntervalSince1970: 10),
documentId: "12345678T",
phoneNumber: "666111222",
email: "tomas.ruiz@47deg.com")
```
### Tagless final
Interpretation to `Validated`
```swift
ValidationRules<ValidatedPartial<NEA<ValidationError>>>
.makeForm(
firstName: "Tomás",
lastName: "Ruiz-López",
birthday: Date(timeIntervalSince1970: 10),
documentId: "12345678T",
phoneNumber: "666111222",
email: "tomas.ruiz@47deg.com")
```
Bonus Track II
I don't need to use NonEmptyArray
thanks to
Semigroup
### Semigroup
Combine two elements of the same type.
### Semigroup
Extending the error model to capture multiple errors
```swift
enum ValidationError: Error {
case emptyFirstName(String)
case emptyLastName(String)
case userTooYoung(Date)
case invalidDocumentId(String)
case invalidPhoneNumber(String)
case invalidEmail(String)
indirect case multiple(first: ValidationError,
rest: [ValidationError])
}
```
### Semigroup
Instance of `Semigroup` for `ValidationError`
```swift
extension ValidationError: Semigroup {
func combine(_ other: ValidationError) -> ValidationError {
switch (self, other) {
case let (.multiple(first: selfFirst, rest: selfRest),
.multiple(first: otherFirst, rest: otherRest)):
return .multiple(first: selfFirst, rest: selfRest + [otherFirst] + otherRest)
case let (.multiple(first: first, rest: rest), _):
return .multiple(first: first, rest: rest + [other])
case let (_, .multiple(first: first, rest: rest)):
return .multiple(first: self, rest: [first] + rest)
default:
return .multiple(first: self, rest: [other])
}
}
}
```
### Semigroup + Tagless Final
Validation functions
```swift
class ValidationRules<F: ApplicativeError>
where F.E == ValidationError {
static func validate(email: String) -> Kind<F, String> {
if email.contains("@") {
return .pure(email)
} else {
return .raiseError(.invalidEmail(email))
}
}
}
```
### Semigroup + Tagless final
Interpretation to `Either`
```swift
ValidationRules<EitherPartial<ValidationError>>
.makeForm(
firstName: "Tomás",
lastName: "Ruiz-López",
birthday: Date(timeIntervalSince1970: 10),
documentId: "12345678T",
phoneNumber: "666111222",
email: "tomas.ruiz@47deg.com")
```
### Semigroup + Tagless final
Interpretation to `Validated`
```swift
ValidationRules<ValidatedPartial<ValidationError>>
.makeForm(
firstName: "Tomás",
lastName: "Ruiz-López",
birthday: Date(timeIntervalSince1970: 10),
documentId: "12345678T",
phoneNumber: "666111222",
email: "tomas.ruiz@47deg.com")
```