Flexible validation with fp-ts
Alessio Bolognino
Posted on February 29, 2020
The Either
monad is a good way to represent the result of a computation, you either have the happy path (conventionally on the Right
) or the error (on the Left
).
I've been working on a problem where I had a third case: the function could succeed with non-fatal warnings.
One way to model this in fp-ts
is fp-ts/lib/These
, from the docs:
These
is a data structure providing "inclusive-or" as opposed toEither
's "exclusive-or". If you interpretEither<E, A>
as suggesting the computation may either fail or succeed (exclusively), thenThese<E, A>
may fail, succeed, or do both at the same time.
On the Left
a ReadOnlyNonEmptyArray
that will contain our Warnings
, on the Right
a password provided by the user. If both values are present it means that it passed the validation but there are some warnings the user should know about.
This example is meant to be a variation of a post (Either vs Validation) by Giulio Canti, the author of fp-ts.
I'm publishing this snippet because I haven't found many (if any) public code bases that make use of These
, pipeable
, getMonad
and it could be useful to someone else (or myself in a few months).
import * as E from 'fp-ts/lib/Either'
import * as TH from 'fp-ts/lib/These';
import {pipe, pipeable} from "fp-ts/lib/pipeable";
import * as RNEA from 'fp-ts/lib/ReadonlyNonEmptyArray';
import { Semigroup } from 'fp-ts/lib/Semigroup';
type Password = string;
type Warning = string
type Warnings = RNEA.ReadonlyNonEmptyArray<Warning>
const minLength = (s: string): E.Either<string, string> =>
s.length >= 6 ? E.right(s) : E.left('at least 6 characters')
const atLeastOneCapital = (arg: Password): TH.These<Warnings, Password> => {
if (!/[A-Z]/g.test(arg)) {
return TH.both(["at least one capital"], arg + "Z")
}
return TH.right(arg);
};
const trimSpaces = (s: Password): TH.These<Warnings, Password> => {
const res = s.trim();
return (res === s)
? TH.right(res)
: TH.both(["no trailing spaces allowed"], res)
}
const lift = (f: (s: string) => E.Either<string, string>): (s: string) => E.Either<Warnings, Password> => {
return (a: Password): E.Either<Warnings, Password> => {
return pipe(
f(a),
E.mapLeft(warning => [warning]));
}
};
const {chain: theseChain} = pipeable(TH.getMonad(RNEA.getSemigroup() as Semigroup<Warnings>));
const validate = (s: Password) => {
return pipe(
TH.right(s),
theseChain(trimSpaces),
theseChain(lift(minLength)),
theseChain(atLeastOneCapital),
TH.fold(
warnings => `invalid password: ${warnings.join(", ")}`,
pass => `password accepted`,
(warnings, pass) => `this is your new password: "${pass}" (we had to improve it: ${warnings.join(", ")})`
)
)
}
console.log(validate("pass "));
// invalid password: no trailing spaces allowed, at least 6 characters
console.log(validate(" pass123!"));
// this is your new password: "pass123!Z" (we had to improve it:
// no trailing spaces allowed, at least one capital)
console.log(validate("Correct Horse"));
// password accepted
Posted on February 29, 2020
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.