Pemrograman Fungsional di TypeScript: Option dan Either

Artikel sebelumnya dalam seri:







  1. Polimorfisme genus orde tinggi
  2. Pola kelas tipe





Pada artikel sebelumnya, kita memeriksa konsep kelas tipe dan secara singkat berkenalan dengan kelas tipe "functor", "monad", "monoid". Dalam artikel ini, saya berjanji untuk mendekati gagasan efek aljabar, tetapi saya memutuskan untuk menulis tentang bekerja dengan tipe nullable dan pengecualian, sehingga diskusi lebih lanjut akan lebih jelas ketika kita beralih ke bekerja dengan tugas dan efek. Oleh karena itu, dalam artikel ini, masih ditujukan untuk pengembang FP pemula, saya ingin berbicara tentang pendekatan fungsional untuk menyelesaikan beberapa masalah aplikasi yang harus Anda tangani setiap hari.







Seperti biasa, saya akan mengilustrasikan contoh menggunakan struktur data dari pustaka fp-ts .







Telah menjadi sikap yang agak buruk untuk mengutip Tony Hoare dengan "kesalahan dalam satu miliar" - pengenalan konsep penunjuk nol ke bahasa ALGOL W. Kesalahan ini, seperti tumor, menyebar ke bahasa lain - C, C ++, Java, dan, terakhir, JS. Kemampuan untuk menetapkan nilai jenis apa pun ke variabel null



menyebabkan efek samping yang tidak diinginkan saat mencoba mengakses dengan pointer ini - runtime mengeluarkan pengecualian, sehingga kode harus dilapisi dengan logika untuk menangani situasi seperti itu. Saya pikir Anda semua pernah bertemu (atau bahkan menulis) kode seperti mie seperti:







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





Dalam contoh ini, saya ingin menarik perhatian Anda pada fakta bahwa kami mendapatkan pemrosesan yang berbeda dari perilaku fungsi yang membentuk tulang punggung logika bisnis kami, tanpa memengaruhi fungsi validasi itu sendiri (yaitu, logika bisnis itu sendiri). Paradigma fungsional tepatnya merakit dari blok bangunan yang ada apa yang dibutuhkan saat ini tanpa perlu refactoring yang kompleks dari keseluruhan sistem.





Ini menyimpulkan artikel saat ini, dan selanjutnya kita akan berbicara tentang Task, TaskEither, dan ReaderTaskEither. Mereka akan memungkinkan kita untuk mendapatkan ide efek aljabar dan memahami apa yang diberikannya dalam hal kemudahan pengembangan.








All Articles