IO монада и изоляция эффектов

IO — функция, оборачивающая побочный эффект. До вызова .run эффект не происходит. Цепочка .map и .chain строит описание вычисления, эффект изолирован в одном месте. Идея — отложить I/O и сделать остальной код чистым.

Идея IO монады

В чистом ФП побочные эффекты (console.log, fs, fetch) запрещены. Чтобы сохранить чистоту цепочек, эффект оборачивают в IO-контейнер:

const createIO = (effect) => {
  const run = () => effect;
  run.map = (f) => createIO( => f(effect));
  run.chain = (f) => createIO( => f(effect).run);
  return run;
};

const readUser = createIO( => fs.readFileSync('user.json()', 'utf-8'));
const parsed = readUser.map(JSON.parse);
const upperName = parsed.map(u => u.name.toUpperCase());

// Никакого I/O ещё не было! Описание готово.
upperName.run; // только сейчас читается файл и происходит трансформация

map vs chain

Метод Когда
.map(f) f возвращает обычное значение
.chain(f) f возвращает другой IO (избегаем IO<IO<T>>)
const readFile = (path) => createIO( => fs.readFileSync(path, 'utf-8'));

// map — даёт IO<IO<string>>, плохо
const result1 = readUser.map(name => readFile(name + '.txt'));

// chain — "схлопывает", даёт IO<string>
const result2 = readUser.chain(name => readFile(name + '.txt'));

Зачем

  • Тестируемость: чистый код легко тестировать; IO-описание можно подменить mock-функцией
  • Композиция: эффекты собираются в pipe/compose так же, как чистые значения
  • Откладывание: одно и то же описание можно выполнить много раз
  • Локализация: все эффекты в одном .run на границе приложения

Изоляция через .run на границе

// Чистая часть программы — собирает описание
const program = pipe(
  readUser,
  parseJson,
  validateUser,
  saveToDb
);

// Граница (main, entry point) — единственное место с эффектами
program.run;

Примеси и дублирование — антипаттерн в IO

Можно и в таком стиле, на самом деле, писать с примесями. Только я не люблю примеси — дублируется всё-таки код.

автор про неудачный рефакторинг Cursor: модель добавила методы через примеси внутрь createIO, что породило дублирование логики run.

LLM плохо знает ФП на JS

Пока что я вижу, что LLM не очень с функциональным программированием на JS. Где ему было обучиться? Мало кто в таком стиле пишет.

Если задачки задавать на Haskell или ClojureScript — LLM справилась бы. На JavaScript ей просто негде научиться ФП.

Чисто функциональный JS — редкость в обучающих данных. LLM по умолчанию генерирует ООП-стиль с заигрыванием под функциональный.

IO для разных эффектов

const log = (msg) => createIO( => console.log(msg));
const random = createIO( => Math.random);
const now = createIO( => Date.now());
const ajax = (url) => createIO( => fetch(url));

Связь с Promise

Promise — частный случай монады, но "слишком умный": запускается сразу при создании. IO откладывает запуск. В ФП-языках для I/O используют именно lazy-структуры (Task, IO, Effect), не Promise.

Effect в реальном мире

Библиотеки:

  • fp-ts (Task, IO, Either)
  • Effect (новая, мощная) — https://effect.website
  • Most.js, Bacon.js — для потоков

Связанные темы

Ресурсы