Функциональное программирование на TypeScript: Option и Either
Предыдущие статьи цикла:
- Полиморфизм родов высших порядков
- Паттерн «класс типов»
В предыдущей статье мы рассмотрели понятие класса типов (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 пользователя и пароль, и проверяющий следующие условия:
- Email содержит знак »@»;
- Email хотя бы символ до знака »@»;
- Email содержит домен после знака »@», состоящий из не менее 1 символа до точки, самой точки и не менее 2 символов после точки;
- Пароль имеет длину не менее 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).
Детальнее об иерархии алгебраических структур можно почитать в вики.
Иерархию классов типов, соответствующих таким алгебраическим структурам, можно продолжать и дальше: в библиотеке fp-ts определены классы типов Semiring, Ring, HeytingAlgebra, BooleanAlgebra, разного рода решётки (lattices) и т.п.
Нам для решения задачи получения списка всех ошибок валидации понадобится две вещи: тип NonEmptyArray (непустой массив) и полугруппа, которую можно определить для этого типа. Вначале напишем вспомогательную функцию lift
, которая будет переводить функцию вида A => Either
в функцию A => Either
:
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. Они позволят нам подойти к идее алгебраических эффектов и понять, что это даёт в плане удобства разработки.