Funktionale Programmierung in TypeScript: Option und entweder

Frühere Artikel in der Reihe:







  1. Gattungspolymorphismus höherer Ordnung
  2. Typklassenmuster





Im vorherigen Artikel haben wir das Konzept einer Typklasse untersucht und uns kurz mit den Typklassen "Funktor", "Monade", "Monoid" vertraut gemacht. In diesem Artikel habe ich versprochen, mich der Idee algebraischer Effekte anzunähern, aber ich habe beschlossen, über das Arbeiten mit nullbaren Typen und Ausnahmen zu schreiben, damit die weitere Diskussion klarer wird, wenn wir mit Aufgaben und Effekten arbeiten. Daher möchte ich in diesem Artikel, der sich immer noch an Anfänger von FP-Entwicklern richtet, über einen funktionalen Ansatz zur Lösung einiger Anwendungsprobleme sprechen, mit denen Sie sich täglich befassen müssen.







Wie immer werde ich Beispiele anhand von Datenstrukturen aus der fp-ts- Bibliothek veranschaulichen .







Es ist schon etwas schlecht geworden, Tony Hoare mit seinem "Fehler in einer Milliarde" zu zitieren - der Einführung des Konzepts eines Nullzeigers auf die Sprache ALGOL W. Dieser Fehler breitete sich wie ein Tumor auf andere Sprachen aus - C, C ++, Java und schließlich JS. Die Möglichkeit, einer Variablen einen Wert eines beliebigen Typs zuzuweisen, null



führt zu unerwünschten Nebenwirkungen, wenn versucht wird, über diesen Zeiger darauf zuzugreifen. Die Laufzeit löst eine Ausnahme aus, sodass der Code für die Behandlung solcher Situationen mit Logik beschichtet werden muss. Ich denke, Sie haben alle nudelartigen Code getroffen (oder sogar geschrieben) wie:







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 diesen Beispielen möchte ich Ihre Aufmerksamkeit auf die Tatsache lenken, dass wir das Verhalten der Funktionen, die das Rückgrat unserer Geschäftslogik bilden, unterschiedlich verarbeiten, ohne die Validierungsfunktionen selbst (dh die Geschäftslogik) zu beeinflussen. Das funktionale Paradigma besteht darin, aus den vorhandenen Bausteinen genau das zusammenzusetzen, was derzeit erforderlich ist, ohne dass ein komplexes Refactoring des gesamten Systems erforderlich ist.





Damit ist der aktuelle Artikel abgeschlossen, und im nächsten werden wir über Task, TaskEither und ReaderTaskEither sprechen. Sie werden es uns ermöglichen, auf die Idee der algebraischen Effekte zu kommen und zu verstehen, was sie in Bezug auf die einfache Entwicklung bieten.








All Articles