DEV Community

Alessio Bolognino
Alessio Bolognino

Posted on

 

Flexible validation with fp-ts

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 to Either's "exclusive-or". If you interpret Either<E, A> as suggesting the computation may either fail or succeed (exclusively), then These<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

Top comments (0)