Programación funcional en TypeScript: opción y cualquiera

Artículos anteriores de la serie:







  1. Polimorfismo de género de orden superior
  2. Patrón de clase





En el artículo anterior, examinamos el concepto de una clase de tipos y nos familiarizamos brevemente con las clases de tipos "functor", "mónada", "monoide". En este artículo, prometí abordar la idea de los efectos algebraicos, pero decidí escribir sobre el trabajo con tipos y excepciones que aceptan valores NULL, para que la discusión adicional sea más clara cuando pasemos a trabajar con tareas y efectos. Por lo tanto, en este artículo, todavía dirigido a los desarrolladores de FP principiantes, quiero hablar sobre un enfoque funcional para resolver algunos de los problemas de aplicaciones con los que tiene que lidiar todos los días.







Como siempre, ilustraré ejemplos usando estructuras de datos de la biblioteca fp-ts .







Ya se ha vuelto algo de mala educación citar a Tony Hoare con su "error en mil millones": la introducción del concepto de puntero nulo al lenguaje ALGOL W. Este error, como un tumor, se extendió a otros lenguajes: C, C ++, Java y, finalmente, JS. La capacidad de asignar un valor de cualquier tipo a una variable null



conduce a efectos secundarios indeseables al intentar acceder mediante este puntero: el tiempo de ejecución genera una excepción, por lo que el código debe estar cubierto de lógica para manejar tales situaciones. Creo que todos conocieron (o incluso escribieron) un código similar a un fideo como:







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
  ]
}
      
      





En estos ejemplos, quiero llamar su atención sobre el hecho de que obtenemos un procesamiento diferente del comportamiento de las funciones que componen la columna vertebral de nuestra lógica empresarial, sin afectar las funciones de validación en sí mismas (es decir, la propia lógica empresarial). El paradigma funcional es precisamente ensamblar a partir de los bloques de construcción existentes lo que se requiere en este momento sin la necesidad de una refactorización compleja de todo el sistema.





Con esto concluye el artículo actual, y en el siguiente hablaremos de Task, TaskEither y ReaderTaskEither. Nos permitirán llegar a la idea de los efectos algebraicos y comprender qué aporta en términos de facilidad de desarrollo.








All Articles