Friday Aug 17, 2018
Today we are open sourcing Validated, a tiny functional Swift library for handling multiple errors: functionality that you don’t get from throwing functions and the
Result
type.
Error handling in Swift is done through a first-class language feature known as throw
and catch
. It
allows you to express the concept of an operation that can fail and provide a data value to represent
what went wrong. It is designed to be a short-circuiting mechanism, which means the moment you throw
an error, the execution of your function is immediately stopped.
Having first-class support for error handling in Swift is nice, but its short-circuiting behavior can be limiting. There are times that we may want to throw multiple times in a function, and have all of those errors accumulated.
Today we are open sourcing Validated, a tiny functional Swift
library for handling multiple errors: functionality that you don’t get from throwing functions and the
Result
type.
throws
Let’s start by looking at the short-circuiting nature of throws
. Imagine we have a User
type that we
are trying to populate with data submitted in a form. Before creating the user we want to perform some
validations, and so we cook up some throwing functions to do those validations:
struct User {
let id: Int
let email: String
let name: String
}
func validate(id: Int) -> Int throws {
guard id > 0 else {
throw Invalid.error("id must be greater than zero")
}
return id
}
func validate(email: String) -> String throws {
guard email.contains("@") else {
throw Invalid.error("email must be valid")
}
return email
}
func validate(name: String) -> String throws {
guard !name.isEmpty else {
throw Invalid.error("name can't be blank")
}
return name
}
func validateUser(id: Int, email: String, name: String) throws -> User {
return User(
id: try validate(id: id),
email: try validate(email: email),
name: try validate(name: name)
)
}
Here we’ve combined a few throwing functions into a single throwing function that may return a User
.
It’s important to note that each of these validations are independent of each other: we don’t need to
validate one field in order to validate the next field.
Let’s see what it looks like to use this validateUser
function:
let user = try validateUser(id: 1, email: "blob@pointfree.co", name: "Blob")
// User(id: 1, email: "blob@pointfree.co", name: "Blob")
Here we have provided all valid values, and so we get a User
value back. However, if the id
, email
, or
name
are invalid, an error is thrown:
let user = try validateUser(id: 1, email: "blob@pointfree.co", name: "")
// throws Invalid.error("name can't be blank")
Unfortunately, if several or all of these inputs are invalid, the first error wins.
let user = try validateUser(id: -1, email: "blobpointfree.co", name: "")
// throws Invalid.error("id must be greater than zero")
This is unfortunate because it creates a subpar user experience. The user can submit a form with invalid data, but then only be notified of the first error we discover. They will have no choice but to fix that one error and submit again to find out if there is anything else wrong with their data.
Validated
Validated
is a Result
-like type that can accumulate multiple
errors. Let’s start over by redefining our validation functions to return Validated
values:
func validate(id: Int) -> Validated<Int, String> {
return id > 0
? .valid(id)
: .error("id must be greater than zero")
}
func validate(email: String) -> Validated<String, String> {
return email.contains("@")
? .valid(email)
: .error("email must be valid")
}
func validate(name: String) -> Validated<String, String> {
return !name.isEmpty
? .valid(name)
: .error("name can't be blank")
}
So, how do we create a User
value from the form data and these functions? Previously we had to create a
custom validateUser
function to accomodate for the throw
ing functions, but the Validated
library
comes with a special function that allows us to instantiate any data type from validated values. It’s
our old friend zip(with:)
!
zip(with: User.init)(
validate(id: 1),
validate(email: "blob@pointfree.co"),
validate(name: "Blob")
)
// .valid(User(id: 1, email: "blob@pointfree.co", name: "Blob"))
You can think of zip(with: User.init)
as a lifting the User
initializer up into the world of validated
values. Here we’ve provided valid values for the fields of the initializer, and so we get a valid user
out the other side.
On the other hand, if we provide an invalid value for a field, say a blank name, then we get an invalid user:
zip(with: User.init)(
validate(id: 1),
validate(email: "blob@pointfree.co"),
validate(name: "")
)
// .invalid(["name can't be blank"])
More importantly, multiple invalid inputs yield an invalid
case with multiple errors.
zip(with: User.init)(
validate(id: -1),
validate(email: "blobpointfree.co"),
validate(name: "")
)
// .invalid([
// "id must be greater than zero",
// "email must be valid",
// "name can't be blank"
// ])
Invalid errors are held in a non-empty array to provide
a compile-time guarantee that you will never encounter an empty invalid
case.
Validated
on GitHubThat’s about all there is to Validated
! It’s a tiny API that should be familiar to you if you’ve ever
used the Result
type, and it provides tangible benefits over the short-circuiting nature of Result
and throws
. It can enhance your error handling today, no need to wait for Swift to catch up!
If you want to give it a spin, check out our open source repo.
👋 Hey there! If you got this far, then you must have enjoyed this post. You may want to also check out Point-Free, a video series covering advanced programming topics in Swift. Consider subscribing today!