Зачем вообще это читать?

В моем проекте достаточно запутанная архитектура. Когда поступает какая-либо задача на доработку — начинается полная головная боль. Иногда даже простая задача вроде «добавить поле ввода в форму» может стать головной болью, которая потом затянется на дни. В итоге проектный менеджер с недовольным лицом начинает спрашивать, когда будет готова текущая фича, а ты до сих пор не можешь разобраться, почему возникает ошибка «cannot read property «abc» of undefined», даже дебаг не помогает.

И тут в один прекрасный момент я решил попробовать внедрить функциональные практики в проект. Его в сообществе очень нахваливают, а всеми любимый GPT утверждает, что «функциональное программирование — это про надежность, распараллеливание без проблем, декларативность, бла-бла-бла». Первая моя мысль была: «Ну, попытка — не пытка, хуже уж точно не будет».

В итоге постепенно я начал приходить к тому, что мой код стал больше похож на какой-то поток данных, в котором можно видеть каждое звено, каждое преобразование данных, при этом без мутаций и лишних вызовов функций. Заметил, что неожиданных и необработанных ошибок обращения к undefined стало гораздо меньше. Тут меня озарило, что ФП меняет сам подход к мышлению и проектированию алгоритма: видно теперь само намерение чуть ли не на поверхности, как будто ты пишешь SQL-запрос к БД, не описывая, каким образом обращаться к базе данных, проверять граничные условия и т.п.

Если ты увидел себя в моих мыслях или проблемах, то эта статья как раз для тебя. Начнем функциональный путь с функторов 🙂

Функтор? Монада? Это какое-то блюдо?

Представь, что у вас есть друг — Игорь. Он тебя пригласил на День рождения, и, как принято у людей, в такой праздник принято имениннику дарить подарок. Ты начинаешь думать про себя, что же можно подарить? Подарок должен быть полезным, применимым в реальной жизни, чтобы твой друг ссался кипятком был доволен с него. Обычно такие презенты выбирают, исходя из интересов и увлечений конкретного человека. И тут, как говорится: «сколько людей — столько и мнений».

Много времени спустя тебе приходит мысль: «подарю-ка я микроволновку!». В самом деле, Игорь — бедный студент, снимает квартиру. А арендодатель не предоставил микроволновку (представляешь лицо Игоря?), и приходится бедному студенту покупать готовые блюда… ну или каждый раз стоять за плитой. Согласитесь — такая себе перспектива.

Не повезло Игорю
Не повезло Игорю

Решено: микроволновка — хороший выбор! Ты приобретаешь ее в своем местном магазине бытовой техники и электроники, и вот она — красавица, уже на руках.

Но как будто бы чего-то не хватает… как ты думаешь, что? Правильно — праздничной коробки! Как же подарить презент, не упаковав предварительно в праздничную коробку? А если вдруг дождь будет на улице — не дарить же испорченную технику? Или в зиму, например? Коробка — она не только же для вида, правильно? 🙂

И в этот момент мы подходим к той самой грани между нашим реальным миром и функциональным. В коробке же может лежать все, что угодно — и ты никогда точно не будешь знать, что там лежит. Можешь поспорить, мол «а если я положил туда что-то?». Уж реальный мир наградил всех хомо сапиенсов ограниченной памятью, и кто-то с феноменальной точностью спустя долгое время ответит, что там лежит, а кто-то забудет уже спустя час. Поэтому согласно нормальному распределению будем считать, что человек (включая Игоря) забудет, что в коробке спустя относительно длительное время.

С точки зрения программирования микроволновка представляет собой некоторые данные (число, строка, может быть, объект, массив и т.д.) — то, что хранится в памяти компьютера. И в рамках ФП1 можно оборачивать эти данные в определенный контекст — как в коробку. И программа не будет знать, какие данные именно находятся в ней, точно так же, как именинник Игорь не подозревает, что же скрывается в подарочной коробке.

А коробка может выполнять одно: поменять содержимое (вдруг мы передумаем дарить микроволновку и захотим вручить, например, мультиварку?). В реальной жизни мы еще можем и развернуть коробку — об этом мы поговорим чуть позже.

Благодаря аналогии микроволновки в коробке мы приходим к важному понятию функтора.

С этим методом особенно хорошо знакомы разработчики JavaScript в работе над массивами (да, массивы — это технически функторы). Конкретно в массивах этот метод позволяет переводить один элемент в другой.

Например:

const array: string[] = ["1", "2", "3", "4", "5", "6"]; // имеем массив строк - нехорошо, вдруг захотим найти сумму? Складывать сейчас - получится "123456"...

const parsedArray: number[] = array.map((element) => parseInt(element, 10)); // [1, 2, 3, 4, 5, 6] - теперь это массив чисел!

const incorrectSum: string = array.reduce((sum, item) => sum + item); // получится "123456" - это не то, что нужно

const correctSum: number = parsedArray.reduce((sum, item) => sum + item); // т.к. parsedArray содержит числа, то суммировать интерпретатор будет как числа, т.е. будет 21 

Как ты можешь видеть, метод map взял каждый элемент из исходного массива и преобразовал по функции parseInt.

Ты можешь создавать своих функторов, сколько угодно — главное, чтобы они реализовывали метод .map

Алекс, ты пишешь про .map — обязательно ли называть его так?

Это не столь важно, как ты обзовешь этот метод, главное — должна остаться суть: этот метод должен четко переводить один тип в другой. Ты можешь написать .transform, .into, .pozhalyistaPreobrazuiMenya и т.д. Правда, обычно принято использовать именно наименование .map — так поймут тебя другие разработчики.

В методе .map обязательно должна быть чистая функция! Это значит, что никаких подключений к БД, вывод в консоль, мутаций внешнего состояния приложения и тому подобного, что никак не отвечает за преобразование данных — быть не должно (в идеале). Иначе нарветесь на неочевидный баг — оно вам вряд ли это надо 🙂

Коробка с подарком — отличная аналогия для функтора: мы можем поменять содержимое, не открывая её, просто сказав: «Пусть вместо микроволновки будет мультиварка» — это и есть .map.

Не каждая коробка умеет безопасно открыться. Обычная функтор-коробка — она только для перевозки и трансформации. А вот если мы хотим взять подарок оттуда (или хотя бы решить, что делать, если коробка оказалась пустой), нам нужна особенная коробка — та, что знает, как себя вести в обоих случаях.

Такие «умные» контейнеры — это уже не просто функторы, а монады (или, точнее, типы данных с дополнительными возможностями). И именно они предоставляют 2метод вроде .fold(), .chain() или .getOrElse() — чтобы вы не рисковали вытаскивать что-то из пустой коробки, а заранее предусмотрели всевозможные сценарии.

Вот пример: ты хочешь положить в подарочную коробку не просто микроволновку, а ещё одну коробку (например, с гарантийным талоном). Обычный функтор бы просто запечатал эту вторую коробку внутри — и получилась бы «коробка в коробке».

А монада говорит: «Стоп! Я вижу, что внутри — тоже коробка. Давай распакуем её и положим содержимое напрямую». Это называется .chain() или .flatMap().

Благодаря этому мы можем строить цепочки операций, где каждая может вернуть «пустую коробку» — и всё равно не сломаемся. Запомните этот аспект — именно он меняет правила игры в императивном коде.

Где это может потребоваться в реальном коде? На самом деле почти во многих местах, и стандартные Promises в JavaScript — один из них

// 1. Запрашиваем пользователя по ID — результат обёрнут в Promise (как "коробка")
const userPromise = fetchUser(123);

// 2. Когда данные придут, извлекаем профиль — но он тоже может потребовать запроса!
//    .then() — это как .chain(): он "распаковывает" Promise и позволяет вернуть новый Promise
const profilePromise = userPromise.then(user => {
  // 3. Возвращаем НОВЫЙ Promise (ещё одну "коробку")
  return fetchProfile(user.id);
});

// 4. А теперь — аватар! Опять .then(), потому что fetchAvatar тоже возвращает Promise
const avatarUrlPromise = profilePromise.then(profile => {
  return fetchAvatar(profile.userId);
});

// 5. Итог: мы получили цепочку, где каждая операция зависит от предыдущей,
//    а "пустые" результаты (ошибки) автоматически ломают цепочку → попадут в .catch()
avatarUrlPromise.then(url => {
  console.log("Аватар загружен:", url);
}).catch(err => {
  console.log("Что-то пошло не так :(", err);
});

Хорошо, мы научились упаковывать подарки в коробки , менять их содержимое, не открывая (map), и даже аккуратно соединять цепочки операций, где каждая может вернуть свою коробку (chain). Но рано или поздно наступает момент, когда нужно решить: а что делать дальше — с этим подарком или без него?

Ведь программа не может вечно носить данные в коробках. В какой-то момент нужно выйти из мира контейнеров и принять решение: показать аватар или заглушку, отправить форму или вывести ошибку, поздравить Игоря или извиниться, что микроволновку украли по дороге.

Именно для этого у «умных» коробок вроде Maybe есть метод вроде .fold() (иногда его называют .match(), .cata() или .getOrElse()). Он говорит:

«Дорогой разработчик, я не знаю, есть ли внутри что-то — но вот два сценария: один — если подарок есть, другой — если коробка пуста. Выбери, что делать в каждом случае.»

Ты можешь подумать: “А разве это не то же самое, что if (value) … else …?” Почти! Но .fold() заставляет тебя обязательно обработать оба случая — и компилятор (или TypeScript) не даст тебе пропустить “пустую коробку”.

Вот как это выглядит на практике:

// Простая реализация Maybe
type Maybe<T> = { 
  isSome: true; 
  value: T; 
} | { 
  isSome: false; 
};

// Фабрика: оборачивает значение в "коробку"
const Some = <T>(value: T): Maybe<T> => ({ isSome: true, value });
const None = <T>(): Maybe<T> => ({ isSome: false });

// Метод .fold() — безопасное "разворачивание"
function fold<T, R>(
  maybe: Maybe<T>,
  onNone: () => R,
  onSome: (value: T) => R
): R {
  if (!maybe.isSome) return onNone();
  return onSome(maybe.value);
}

// А теперь — пример из жизни:
const maybeMicrowave = Math.random() > 0.3 
  ? Some("Микроволновка Panasonic NN-SN68KS") 
  : None<string>();

// Используем .fold(), как если бы это был метод:
const message = fold(
  maybeMicrowave,
  // Сценарий, если подарка нет
  () => "Упс! Подарок не дошёл — видимо, дождь испортил коробку 😢",
  // Сценарий, если подарок есть
  (gift) => `Игорь, лови свою ${gift}! Грецкие орехи уже внутри? 🥜`
);

Итак, резюмируем

  1. Вкратце мы познакомились с функторами и монадами (не забудьте поблагодарить Игоря и поздравить с Днем рождения!);
  2. Краем глаза увидели, как выглядят эти функторы и монады в коде;
  3. Узнали, что можно делать с функторами и монадами.

Maybe: мой первый шаг в мире монад

Мы уже коснулись немного Maybe, когда говорили о понятиях функторов и монад. В мире ФП он решает, какой сценарий поведения использовать в зависимости от присутствия значения. Более того, Maybe может даже возвращать значения в зависимости от сценария. И, казалось бы, это небольшая абстракция — но в коде проделывает просто уникальные вещи!

1. Динамические колонки

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

В TSX-разметке я обычно что-то вот такое делаю:

import { useMemo } from 'react';
import { baseColumns } from '@/entities/good';
import type { Good } from '@/entities/good';
import { Table } from '@/ui';
import type { TableProps } from '@/ui';

type GoodsTableProps = TableProps<Good> & { 
extraColumns?: TableProps['columns'];
}

export const GoodsTable = ({ extraColumns, ...tableProps }: GoodsTableProps) => {
const columns = useMemo(() => {
return [...baseColumns, ...extraColumns]
}, [baseColumns, extraColumns]);

return <Table {...tableProps} columns={columns} />
}

Выглядит поначалу логично: если есть дополнительные колонки, значит их нужно объединять с предоставляемыми колонками, окей. А теперь представьте, что заказчик говорит вам, что порядок следования колонок нужно поменять. Очевидное решение — нужно добавить пропс columnSorter, который будет описывать, как сортировать колонки между собой

import { useMemo } from 'react';
import { baseColumns } from '@/entities/good';
import type { Good } from '@/entities/good';
import { Table } from '@/ui';
import type { TableProps } from '@/ui';

type GoodsTableProps = TableProps<Good> & { 
extraColumns?: TableProps['columns'];
columnSorter?: Record<keyof Good, number>;
}

export const GoodsTable = ({ extraColumns, columnSorter ...tableProps }: GoodsTableProps) => {

const columns = useMemo(() => {
if (columnSorter !== undefined) {
return getSortedColumns([...baseColumns, ...extraColumns])
} else return [...baseColumns, ...extraColumns]
}, [baseColumns, extraColumns]);

return <Table {...tableProps} columns={columns} />
}

Заметили, что здесь не так? Правильно, extraColumns может и не быть, и когда попытаемся развернуть extraColumns внутри useMemo. А это приведет к тому, что приложение повалится, и будет волшебное Uncaught TypeError: undefined is not iterable: мы не учли тот факт, что extraColumns может оказаться пустым.

Решить это можно как-то вот так:

import { useMemo } from 'react';
import { baseColumns } from '@/entities/good';
import type { Good } from '@/entities/good';
import { Table } from '@/ui';
import type { TableProps } from '@/ui';

type GoodsTableProps = TableProps<Good> & { 
extraColumns?: TableProps['columns'];
columnSorter?: Record<keyof Good, number>;
}

export const GoodsTable = ({ extraColumns, columnSorter ...tableProps }: GoodsTableProps) => {

const columns = useMemo(() => {
if (extraColumns === undefined || extraColumns === null) return baseColumns;
if (columnSorter !== undefined) {
return getSortedColumns([...baseColumns, ...extraColumns], columnSorter)
} else return [...baseColumns, ...extraColumns]
}, [baseColumns, extraColumns]);

return <Table {...tableProps} columns={columns} />
}

Выглядит теперь вроде как надо.

Первое, что бросается в глаза — повторяющаяся конструкция [...baseColumns, ...extraColumns]. Можно это вынести в отдельную переменную, после проверки на пустоту пропса extraColumns

import { useMemo } from 'react';
import { baseColumns } from '@/entities/good';
import type { Good } from '@/entities/good';
import { Table } from '@/ui';
import type { TableProps } from '@/ui';

type GoodsTableProps = TableProps<Good> & { 
extraColumns?: TableProps['columns'];
columnSorter?: Record<keyof Good, number>;
}

export const GoodsTable = ({ extraColumns, columnSorter ...tableProps }: GoodsTableProps) => {

const columns = useMemo(() => {
if (extraColumns === undefined || extraColumns === null) return baseColumns;
const realColumns = [...baseColumns, ...extraColumns];
if (columnSorter !== undefined) {
return getSortedColumns(realColumns, columnSorter)
} else return realColumns
}, [baseColumns, extraColumns]);

return <Table {...tableProps} columns={columns} />
}

Примерно так выглядело бы на проде решение данной задачи. Проблема в том, что на этапе разработки не всегда бывает очевидно, что может быть undefined, а что — нет — и иногда даже TypeScript не помогает. И вместо того, чтобы гадать, будет ли пустым определенное значение — приходит на помощь как раз Maybe

import { useMemo } from 'react';
import { baseColumns } from '@/entities/good';
import type { Good } from '@/entities/good';
import { Table } from '@/ui';
import type { TableProps } from '@/ui';
import { Maybe } from '@/shared';

type GoodsTableProps = TableProps<Good> & { 
extraColumns?: TableProps['columns'];
columnSorter?: Record<keyof Good, number>;
}

export const GoodsTable = ({ extraColumns, columnSorter ...tableProps }: GoodsTableProps) => {

const columns = useMemo(() => {
//Формируем контейнер из Maybe.of
return Maybe.of(extraColumns)
// обрабатываем случай, когда доп колонки есть
.map(cols => {
  const realColumns = [...baseColumns, ...cols];
  // мы не уверены, что columnSorter есть, поэтому возвращаем отсортированные колонки, если есть
  return Maybe.of(columnSorter).map(sorter => getSortedColumns(realColumns, sorter)).getOrElse(realColumns)
}).getOrElse(baseColumns)
}, [baseColumns, extraColumns]);

return <Table {...tableProps} columns={columns} />
}

Данный стиль предоставляет более естественный порядок обработки колонок, сверху вниз — как в трубе. Такое, как правило, легче читается

2. Два источника данных, одна правда

В философии React принято считать, что для отображения данных должен быть один источник данных, дабы избежать проблем синхронизации состояний в UI-компонентах. В самом деле, не очень хочется потом писать сотни useEffect только ради этого, потом это еще нужно отлаживать, тестировать на разных кейсах. Дай боже, чтобы это заработало. Ну или хотя бы с минимальным количеством багов 🙂

Тем не менее, мы с вами живем далеко не в идеальном мире, и ради определенных требований от заказчика приходится чем-то жертвовать. И я попал в такую ситуацию, когда у меня согласно бизнес-требованию должно быть два источника правды.

В чем смысл: есть определенный <Select>, которому нужно передать значение из сущности EntityA1 или EntityA2, но только одно. При этом эти две сущности — массив (даже не спрашивайте почему — таков суровый бэк). Более того, просто передать «сырое» значение сущности — этого недостаточно, нужно преобразовать в объект, пригодный для отображения в Select

const Page = () => {
...
return (
<div>
...
<Select value={entity1 && entity1.length > 0 ? { label: entity1[0].title, value: entity1[0].id} : entity2 && entity2.length > 0 ? { label: entity2[0].title, value: entity[0].id}} : undefined />
</div>
)
}

Внушительная конструкция, согласитесь? Кроме того, за такой код на код-ревью точно надают по рукам.

Как можно обратить внимание, здесь идет проверка на существование entity, берется 1 элемент — и делается из него объект для отображения. Ну или undefined, если значения нет.

С помощью Maybe эта задача решается очень лаконично:

const Page = () => {
...
return (
<div>
...
<Select value={maybe(entity1).alt(maybe(entity2)).map(entity => entity[0]).map(entity => ({ label: entity.title, value: entity.id})).getOrElse(undefined)} />
</div>
)
}

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

import first from 'lodash/first';
import { transformToValue } from '@/utils';

const Page = () => {
...
return (
<div>
...
<Select value={maybe(entity1).alt(maybe(entity2)).map(first).map(transformToValue).getOrElse(undefined)} />
</div>
)
}

Вот так лучше, правда?

Стоит ли вам начинать?

Подход для организации кода с помощью монад — бесспорно, мощный инструмент. Благодаря нему мысль, выраженная в коде, становится более прозрачной и ясной. Этот подход диктуется стилем декларативного программирования, когда сниппет отвечает на вопрос «что делает?», а не «как это делается».

Тем не менее, при всех достоинствах функциональных абстракций — это не является серебряной пулей. Чтобы понять, насколько тебе подходит этот стиль программирования, попробуй ответить на несколько вопросов:

  1. Большая ли команда в проекте, и насколько она подготовлена к разработке в парадигме ФП? Разумеется, было бы странно внедрять этот подход в проектах, в которых есть уже устоявшийся code-style. Нужно будет время на подготовку, перестроение мышления. Нужно учитывать и психологические особенности каждого сотрудника: кому-то этот подход вообще не зайдет. Вы можете в этом лично убедиться, посмотрев похожие статьи на хабре или обсуждения в реддите.
  2. Критично ли бороться за доли миллисекунд времени исполнения кода? Дело в том, что TypeScript, на чем написаны примеры выше, не является исконно функциональным языком, скорее мультипарадигмальным (т.е. поощрает разные стили написания кода). Исконно функциональные языки, вроде Haskell, сильно оптимизированы под функциональную парадигму (очевидно же?). Именно поэтому любой другой язык вроде TypeScript будет проигрывать в производительности хаскелю или любому другому функциональному ЯП в рамках функционального программирования.

Заключение

Функциональное программирование — не серебряная пуля. Но это мощный инструмент в арсенале разработчика, который помогает:

  • Делать код предсказуемее (чистые функции, явные эффекты);
  • Снижать когнитивную нагрузку (меньше вложенности, больше пайплайнов);
  • Писать тестируемее (изолированные преобразования данных).

Если после прочтения вы подумали: «Хм, а ведь это действительно может улучшить понимание кода» — значит, статья сработала. Если же пока «не зашло» — тоже ок. ФП не требует немедленного обращения, оно ждёт, когда вы будете готовы.

Спасибо, что дочитали! Если остались вопросы, нашли неточность или хотите поделиться своим опытом с монадами — пишите в комментариях, буду рад обсудить некоторые моменты.

  1. Функциональное программирование ↩︎
  2. Да, я немного упрощаю: на самом деле .fold() — это не «монадная» штука, а способ безопасно распаковать контейнер. Но поскольку мы всё равно используем его вместе с map и chain, давайте пока думать об этом как об одном наборе инструментов — а детали оставим для вечерних размышлений с чашкой чая ☕ ↩︎