Functional programming in TypeScript: Option and Either

Previous articles in the series:







  1. Higher order genus polymorphism
  2. Typeclass pattern





In the previous article, we looked at the concept of a type class and briefly got acquainted with the type classes "functor", "monad", "monoid". In this article, I promised to approach the idea of ​​algebraic effects, but I decided to write about working with nullable types and exceptions, so that the further presentation would be clearer when we move on to working with tasks and effects. Therefore, in this article, still aimed at aspiring FP developers, I want to talk about a functional approach to solving some of the application problems that you have to deal with every day.







As always, I will illustrate examples using data structures from the fp-ts library .







It has already become somewhat bad manners to quote Tony Hoare with his "mistake in a billion" - the introduction of the concept of a null pointer to the ALGOL W language. This error, like a tumor, has spread to other languages ​​- C, C ++, Java, and, finally, JS. The ability to assign a value to a variable of any type null



leads to undesirable side effects when trying to access by this pointer - the runtime throws an exception, so the code has to be coated with logic for handling such situations. I think you've all met (or even wrote) noodle-like code like this:







function foo(arg1, arg2, arg3) {
  if (!arg1) {
    return null;
  }

  if (!arg2) {
    throw new Error("arg2 is required")
  }

  if (arg3 && arg3.length === 0) {
    return null;
  }

  // -  -,  arg1, arg2, arg3
}
      
      





TypeScript β€” strictNullChecks



-nullable null



, TS2322. - , never



, . , API add :: (x: number, y: number) => number



, - , . , Java throws



, try-catch



, TypeScript -, () JSDoc-, .







, . , JVM-: Error () β€” , (, ); exception () β€” , (, ). JS/TS- , ( throw new Error()



), . , β€” Β« , Β».

β€” Β« Β» β€” .







Option<A>



β€” nullable-



JS TS nullable- optional chaining nullish coalescing. , , . , optional chaining β€” if (a != null) {}



, Go:







const getNumber = (): number | null => Math.random() > 0.5 ? 42 : null;
const add5 = (n: number): number => n + 5;
const format = (n: number): string => n.toFixed(2);

const app = (): string | null => {
  const n = getNumber();
  const nPlus5 = n != null ? add5(n) : null;
  const formatted = nPlus5 != null ? format(nPlus5) : null;
  return formatted;
};
      
      





Option<A>



, : None



, Some



A



:







type Option<A> = None | Some<A>;

interface None {
  readonly _tag: 'None';
}

interface Some<A> {
  readonly _tag: 'Some';
  readonly value: A;
}
      
      





, , . «», null, , .







import { Monad1 } from 'fp-ts/Monad';

const URI = 'Option';
type URI = typeof URI;

declare module 'fp-ts/HKT' {
  interface URItoKind<A> {
    readonly [URI]: Option<A>;
  }
}

const none: None = { _tag: 'None' };
const some = <A>(value: A) => ({ _tag: 'Some', value });

const Monad: Monad1<URI> = {
  URI,
  // :
  map: <A, B>(optA: Option<A>, f: (a: A) => B): Option<B> => {
    switch (optA._tag) {
      case 'None': return none;
      case 'Some': return some(f(optA.value));
    }
  },
  //  :
  of: some,
  ap: <A, B>(optAB: Option<(a: A) => B>, optA: Option<A>): Option<B> => {
    switch (optAB._tag) {
      case 'None': return none;
      case 'Some': {
        switch (optA._tag) {
          case 'None': return none;
          case 'Some': return some(optAB.value(optA.value));
        }
      }
    }
  },
  // :
  chain: <A, B>(optA: Option<A>, f: (a: A) => Option<B>): Option<B> => {
    switch (optA._tag) {
      case 'None': return none;
      case 'Some': return f(optA.value);
    }
  }
};
      
      





, . β€” chain



( bind flatMap ) of



(pure return).







JS/TS , Haskell Scala, nullable-, , , β€” , (, , ) (Promise/A+, async/await, optional chaining). , - TC39, , .

Option fp-ts/Option



, , :







import { pipe, flow } from 'fp-ts/function';
import * as O from 'fp-ts/Option';

import Option = O.Option;

const getNumber = (): Option<number> => Math.random() > 0.5 ? O.some(42) : O.none;
//     !
const add5 = (n: number): number => n + 5;
const format = (n: number): string => n.toFixed(2);

const app = (): Option<string> => pipe(
  getNumber(),
  O.map(n => add5(n)), //   O.map(add5)
  O.map(format)
);
      
      





, , app



:







const app = (): Option<string> => pipe(
  getNumber(),
  O.map(flow(add5, format)),
);
      
      





N.B. - ( ), : Β« -Β», Option ( ) - ( ). ///etc , -. β€” , Free- Tagless Final. , β€” .


Either<E, A>



β€” ,



. , β€” , - . β€” , Option, Either:







type Either<E, A> = Left<E> | Right<A>;

interface Left<E> {
  readonly _tag: 'Left';
  readonly left: E;
}

interface Right<A> {
  readonly _tag: 'Right';
  readonly right: A;
}
      
      





Either<E, A>



, : , E



, , A



. , , β€” . Either β€” ////etc, fp-ts/Either



. :







import { Monad2 } from 'fp-ts/Monad';

const URI = 'Either';
type URI = typeof URI;

declare module 'fp-ts/HKT' {
  interface URItoKind2<E, A> {
    readonly [URI]: Either<E, A>;
  }
}

const left = <E, A>(e: E) => ({ _tag: 'Left', left: e });
const right = <E, A>(a: A) => ({ _tag: 'Right', right: a });

const Monad: Monad2<URI> = {
  URI,
  // :
  map: <E, A, B>(eitherEA: Either<E, A>, f: (a: A) => B): Either<E, B> => {
    switch (eitherEA._tag) {
      case 'Left':  return eitherEA;
      case 'Right': return right(f(eitherEA.right));
    }
  },
  //  :
  of: right,
  ap: <E, A, B>(eitherEAB: Either<(a: A) => B>, eitherEA: Either<A>): Either<B> => {
    switch (eitherEAB._tag) {
      case 'Left': return eitherEAB;
      case 'Right': {
        switch (eitherEA._tag) {
          case 'Left':  return eitherEA;
          case 'Right': return right(eitherEAB.right(eitherEA.right));
        }
      }
    }
  },
  // :
  chain: <E, A, B>(eitherEA: Either<E, A>, f: (a: A) => Either<E, B>): Either<E, B> => {
    switch (eitherEA._tag) {
      case 'Left':  return eitherEA;
      case 'Right': return f(eitherEA.right);
    }
  }
};
      
      





, , . , Either, . , API , email , :







  1. Email Β«@Β»;
  2. Email Β«@Β»;
  3. Email Β«@Β», 1 , 2 ;
  4. 1 .


, . , , :







interface Account {
  readonly email: string;
  readonly password: string;
}

class AtSignMissingError extends Error { }
class LocalPartMissingError extends Error { }
class ImproperDomainError extends Error { }
class EmptyPasswordError extends Error { }

type AppError =
  | AtSignMissingError
  | LocalPartMissingError
  | ImproperDomainError
  | EmptyPasswordError;
      
      





- :







const validateAtSign = (email: string): string => {
  if (!email.includes('@')) {
    throw new AtSignMissingError('Email must contain "@" sign');
  }
  return email;
};
const validateAddress = (email: string): string => {
  if (email.split('@')[0]?.length === 0) {
    throw new LocalPartMissingError('Email local-part must be present');
  }
  return email;
};
const validateDomain = (email: string): string => {
  if (!/\w+\.\w{2,}/ui.test(email.split('@')[1])) {
    throw new ImproperDomainError('Email domain must be in form "example.tld"');
  }
  return email;
};
const validatePassword = (pwd: string): string => {
  if (pwd.length === 0) {
    throw new EmptyPasswordError('Password must not be empty');
  }
  return pwd;
};

const handler = (email: string, pwd: string): Account => {
  const validatedEmail = validateDomain(validateAddress(validateAtSign(email)));
  const validatedPwd = validatePassword(pwd);

  return {
    email: validatedEmail,
    password: validatedPwd,
  };
};
      
      





, β€” API , . Either:







import * as E from 'fp-ts/Either';
import { pipe } from 'fp-ts/function';
import * as A from 'fp-ts/NonEmptyArray';

import Either = E.Either;
      
      





, , Either' β€” , throw



, (Left) :







// :
const validateAtSign = (email: string): string => {
  if (!email.includes('@')) {
    throw new AtSignMissingError('Email must contain "@" sign');
  }
  return email;
};

// :
const validateAtSign = (email: string): Either<AtSignMissingError, string> => {
  if (!email.includes('@')) {
    return E.left(new AtSignMissingError('Email must contain "@" sign'));
  }
  return E.right(email);
};

//        :
const validateAtSign = (email: string): Either<AtSignMissingError, string> =>
  email.includes('@') ?
    E.right(email) :
    E.left(new AtSignMissingError('Email must contain "@" sign'));
      
      





:







const validateAddress = (email: string): Either<LocalPartMissingError, string> =>
  email.split('@')[0]?.length > 0 ?
    E.right(email) :
    E.left(new LocalPartMissingError('Email local-part must be present'));

const validateDomain = (email: string): Either<ImproperDomainError, string> =>
  /\w+\.\w{2,}/ui.test(email.split('@')[1]) ?
    E.right(email) :
    E.left(new ImproperDomainError('Email domain must be in form "example.tld"'));

const validatePassword = (pwd: string): Either<EmptyPasswordError, string> =>
  pwd.length > 0 ? 
    E.right(pwd) : 
    E.left(new EmptyPasswordError('Password must not be empty'));
      
      





handler



. chainW



β€” chain



, (type widening). , , fp-ts:







  • W



    type Widening β€” . , Either/TaskEither/ReaderTaskEither , -:







    // ,    A, B, C, D,   E1, E2, E3, 
    //   foo, bar, baz,   :
    declare const foo: (a: A) => Either<E1, B>
    declare const bar: (b: B) => Either<E2, C>
    declare const baz: (c: C) => Either<E3, D>
    declare const a: A;
    //  ,   chain       Either:
    const willFail = pipe(
      foo(a),
      E.chain(bar),
      E.chain(baz)
    );
    
    //  :
    const willSucceed = pipe(
      foo(a),
      E.chainW(bar),
      E.chainW(baz)
    );
          
          





  • T



    β€” Tuple (, sequenceT



    ), ( EitherT, OptionT ).
  • S



    structure β€” , traverseS



    sequenceS



    , Β« β€” Β».
  • L



    lazy, .


β€” , apSW



: ap



Apply, type widening , .







handler



. chainW



, - AppError:







const handler = (email: string, pwd: string): Either<AppError, Account> => pipe(
  validateAtSign(email),
  E.chainW(validateAddress),
  E.chainW(validateDomain),
  E.chainW(validEmail => pipe(
    validatePassword(pwd),
    E.map(validPwd => ({ email: validEmail, password: validPwd })),
  )),
);
      
      





? -, handler



β€” Account, AtSignMissingError, LocalPartMissingError, ImproperDomainError, EmptyPasswordError. -, handler



β€” Either , , , - .







NB: , β€” . TypeScript JavaScript , :

const bad = (cond: boolean): Either<never, string> => {
  if (!cond) {
    throw new Error('COND MUST BE TRUE!!!');
  }
  return E.right('Yay, it is true!');
};
      
      







, , . , , Either/IOEither tryCatch



, β€” TaskEither.tryCatch



.

β€” . -, Option, , , . .







Either - β€” Validation. -, , β€” . , Validation , E



concat :: (a: E, b: E) => E



Semigroup. Validation Either , . , ( handler



) , , (validateAtSign, validateAddress, validateDomain, validatePassword).







,

:







  • Magma (), β€” , concat :: (a: A, b: A) => A



    . .
  • concat



    , (Semigroup). , , , β€” .
  • (unit) β€” , , β€” (Monoid).
  • , inverse :: (a: A) => A



    , , (Group).


Groupoid hierarchy

.







, , : fp-ts Semiring, Ring, HeytingAlgebra, BooleanAlgebra, (lattices) ..







: NonEmptyArray ( ) , . lift



, A => Either<E, B>



A => Either<NonEmptyArray<E>, B>



:







const lift = <Err, Res>(check: (a: Res) => Either<Err, Res>) => (a: Res): Either<NonEmptyArray<Err>, Res> => pipe(
  check(a),
  E.mapLeft(e => [e]),
);
      
      





, , sequenceT



fp-ts/Apply:







import { sequenceT } from 'fp-ts/Apply';
import NonEmptyArray = A.NonEmptyArray;

const NonEmptyArraySemigroup = A.getSemigroup<AppError>();
const ValidationApplicative = E.getApplicativeValidation(NonEmptyArraySemigroup);

const collectAllErrors = sequenceT(ValidationApplicative);

const handlerAllErrors = (email: string, password: string): Either<NonEmptyArray<AppError>, Account> => pipe(
  collectAllErrors(
    lift(validateAtSign)(email),
    lift(validateAddress)(email),
    lift(validateDomain)(email),
    lift(validatePassword)(password),
  ),
  E.map(() => ({ email, password })),
);
      
      





, , :







> handler('user@host.tld', '123')
{ _tag: 'Right', right: { email: 'user@host.tld', password: '123' } }

> handler('user_host', '')
{ _tag: 'Left', left: AtSignMissingError: Email must contain "@" sign }

> handlerAllErrors('user_host', '')
{
  _tag: 'Left',
  left: [
    AtSignMissingError: Email must contain "@" sign,
    ImproperDomainError: Email domain must be in form "example.tld",
    EmptyPasswordError: Password must not be empty
  ]
}
      
      





In these examples, I want to draw your attention to the fact that we get different processing of the behavior of the functions that make up the backbone of our business logic, without affecting the validation functions themselves (that is, the very business logic). The functional paradigm is precisely to assemble from the existing building blocks what is required at the moment without the need for complex refactoring of the entire system.





This concludes the current article, and in the next we will talk about Task, TaskEither and ReaderTaskEither. They will allow us to get to the idea of ​​algebraic effects and understand what it gives in terms of ease of development.








All Articles