Функциональное программирование на TypeScript: Option и Either

Предыдущие статьи цикла:


  1. Полиморфизм родов высших порядков
  2. Паттерн «класс типов»

В предыдущей статье мы рассмотрели понятие класса типов (type class) и бегло познакомились с классами типов «функтор», «монада», «моноид». В этой статье я обещал подойти к идее алгебраических эффектов, но решил всё-таки написать про работу с nullable-типами и исключительными ситуациями, чтобы дальнейшее изложение было понятнее, когда мы перейдем к работе с задачами (tasks) и эффектами. Поэтому в этой статье, всё еще рассчитанной на начинающих ФП-разработчиков, я хочу поговорить о функциональном подходе к решению некоторых прикладных проблем, с которыми приходится иметь дело каждый день.

Как всегда, я буду иллюстрировать примеры с помощью структур данных из библиотеки fp-ts.

Стало уже некоторым моветоном цитировать Тони Хоара с его «ошибкой на миллиард» — введению в язык ALGOL W понятия нулевого указателя. Эта ошибка, как опухоль, расползлась по другим языкам — C, C++, Java, и, наконец, JS. Возможность присвоения переменной любого типа значения null приводит к нежелательным побочным эффектам при попытке доступа по этому указателю — среда исполнения выбрасывает исключение, поэтому код приходится обмазывать логикой обработки таких ситуаций. Думаю, вы все встречали (а то и писали) лапшеобразный код вида:

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 — замена 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 можно рассматривать как контейнер, который может находиться в одном из двух возможных состояний: None в случае отсутствия значения, и Some в случае наличия значения типа A:

type Option = None | Some;

interface None {
  readonly _tag: 'None';
}

interface Some {
  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 {
    readonly [URI]: Option;
  }
}

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

const Monad: Monad1 = {
  URI,
  // Функтор:
  map: (optA: Option, f: (a: A) => B): Option => {
    switch (optA._tag) {
      case 'None': return none;
      case 'Some': return some(f(optA.value));
    }
  },
  // Аппликативный функтор:
  of: some,
  ap: (optAB: Option<(a: A) => B>, optA: Option): Option => {
    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: (optA: Option, f: (a: A) => Option): Option => {
    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 => 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 => pipe(
  getNumber(),
  O.map(n => add5(n)), // или просто O.map(add5)
  O.map(format)
);

Благодаря тому, что один из законов для функтора подразумевает сохранение композиции функций, мы можем переписать app еще короче:

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

N.B. В этом крохотном примере не нужно смотреть на конкретную бизнес-логику (она умышленно сделана примитивной), а важно подметить одну вещу касательно функциональной парадигмы в целом: мы не просто «использовали функцию по-другому», мы абстрагировали общее поведение для вычислительного контекста контейнера Option (изменение значения в случае его наличия) от бизнес-логики (работа с числами). При этом само вынесенное в функтор/монаду/аппликатив/etc поведение можно переиспользовать в других местах приложения, получив один и тот же предсказуемый порядок вычислений в контексте разной бизнес-логики. Как это сделать — мы рассмотрим в последующих статьях, когда будем говорить про Free-монады и паттерн Tagless Final. С моей точки зрения, это одна из сильнейших сторон функциональной парадигмы — отделение общих абстрактных вещей с последующим переиспользованием их для композиции в более сложные структуры.

Either — вычисления, которые могут идти двумя путями

Теперь поговорим про исключения. Как я уже писал выше, исключительная ситуация — это нарушение обычного потока исполнения логики программы, на которое как-то необходимо среагировать. При этом выразительных средств в самом языке у нас нет —, но мы сможем обойтись структурой данных, несколько схожей с Option, которая называется Either:

type Either = Left | Right;

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

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

Тип Either выражает идею вычислений, которые могут пойти по двум путям: левому, завершающемуся значением типа 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 {
    readonly [URI]: Either;
  }
}

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

const Monad: Monad2 = {
  URI,
  // Функтор:
  map: (eitherEA: Either, f: (a: A) => B): Either => {
    switch (eitherEA._tag) {
      case 'Left':  return eitherEA;
      case 'Right': return right(f(eitherEA.right));
    }
  },
  // Аппликативный функтор:
  of: right,
  ap: (eitherEAB: Either<(a: A) => B>, eitherEA: Either): Either => {
    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: (eitherEA: Either, f: (a: A) => Either): Either => {
    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 => {
  if (!email.includes('@')) {
    return E.left(new AtSignMissingError('Email must contain "@" sign'));
  }
  return E.right(email);
};

// После упрощения через тернарный оператор и инверсии условия:
const validateAtSign = (email: string): Either =>
  email.includes('@') ?
    E.right(email) :
    E.left(new AtSignMissingError('Email must contain "@" sign'));

Аналогичным образом переписываются другие функции:

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

const validateDomain = (email: string): Either =>
  /\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 =>
  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
    declare const bar: (b: B) => Either
    declare const baz: (c: C) => Either
    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 => 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 => {
  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 в функцию A => Either, B>:

const lift = (check: (a: Res) => Either) => (a: Res): Either, 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();
const ValidationApplicative = E.getApplicativeValidation(NonEmptyArraySemigroup);

const collectAllErrors = sequenceT(ValidationApplicative);

const handlerAllErrors = (email: string, password: string): Either, 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
  ]
}

В этих примерах я хочу обратить ваше внимание на то, что мы получаем различную обработку поведения функций, составляющих костяк нашей бизнес-логики, не затрагивая при этом сами функции валидации (т.е. ту самую бизнес-логику). Функциональная парадигма как раз и заключается в том, чтобы из наличествующих строительных блоков собирать то, что требуется в текущий момент без необходимости сложного рефакторинга всей системы.

На этом текущую статью я заканчиваю, а в следующей будем говорить уже про Task, TaskEither и ReaderTaskEither. Они позволят нам подойти к идее алгебраических эффектов и понять, что это даёт в плане удобства разработки.

© Habrahabr.ru