Validated
The Validated
type, like Either
represents the existence of one of two
types. An instance of Validated
is an instance of Valid
or Invalid
.
Additionaly ValidatedNel<E, A>
is an alias of type Validated<NonEmptyIList<E>, A>
which describes either one or more errors (NonEmptyIList<A>>
) or a successful
value (A
).
This introduces a new datastructure used in Ribs, NonEmptyIList
, which is a
list that contains at least one element. You can read more about it on the
NonEmptyIList page, but for the purposes of
Validated
just understand it's a list with at least one element.
Motivation
Let's expand and improve on the example from Either
where we wanted to define
a function to create a new user from a set of inputs. Here's our domain model:
// Some type aliases for clarity
typedef Name = String;
typedef Alias = String;
typedef Age = int;
final class User {
final Name name;
final Alias alias;
final Age age;
const User(this.name, this.alias, this.age);
}
Either<String, User> userEither(Name name, Alias alias, Age age) =>
throw UnimplementedError();
Remember that our function built on Either
was capable of returning a reason
as to why the user couldn't be created (e.g. no name provided, too young, etc.).
But consider the case where there are multiple issues with the input. In our previous implementation, once an error is encountered, that error is returned and we can try again, only to run into yet another error. Frustrating to be sure!
In this case Validated
can help us by accumuulating all validation errors
in case of a failure or returning the validated user. Let's take a quick look
at what this could look like:
ValidatedNel<String, Name> validateName(Name name) =>
name.isEmpty ? 'No name provided!'.invalidNel() : name.validNel();
ValidatedNel<String, Alias> validateAlias(Alias alias) =>
alias.isEmpty ? 'No alias provided!'.invalidNel() : alias.validNel();
ValidatedNel<String, Age> validateAge(Age age) =>
age < 18 ? 'Too young!'.invalidNel() : age.validNel();
ValidatedNel<String, User> createUser(User user) => (
validateName(user.name),
validateAlias(user.alias),
validateAge(user.age),
).mapN(User.new);
With this definition, we can validate all the individual pieces of our user data and if everything looks good, get our user! If one or more of the pieces doesn't pass the check, we'll get information about everything that needs fixed! Here's how it works in practice:
// Valid(Instance of 'User')
final good = createUser(const User('John', 'Doe', 30));
// Invalid(NonEmptyIList(No name provided!))
final noName = createUser(const User('', 'Doe', 30));
// Invalid(NonEmptyIList(Too young!))
final tooYoung = createUser(const User('John', 'Doe', 7));
// Invalid(NonEmptyIList(No name provided!, Too young!))
final noAliasAndTooYoung = createUser(const User('John', '', 10));
// Invalid(NonEmptyIList(No name provided!, No alias provided!))
final noNameNoAlias = createUser(const User('', '', 75));
Once we run our user through the validation, we can decide what action(s) to take. If the validation failed, we may show an error. If we succeed, maybe store the user to a database.
final succeeded = createUser(const User('John', 'Doe', 30));
final failed = createUser(const User('', 'Doe', 3));
void notifyUser(String message) => throw UnimplementedError();
void storeUser(User user) => throw UnimplementedError();
void handleCreateUser() {
succeeded.fold(
(errors) =>
notifyUser(errors.mkString(start: 'User creation failed: ', sep: ',')),
(user) => storeUser(user),
);
}