Раздел 2 · Программирование / CS

Построй свою IO-монаду

senior~260 мин

без входаВойди, чтобы отмечать прогресс, вести заметки и решать задачи в редакторе. войти

Построй свою IO-монаду

В Haskell IO a это не функция, которая “делает side effect”. Это описание программы: дерево инструкций, которое runtime (GHC RTS) интерпретирует. Само по себе IO-значение, чистые данные. Эффекты происходят только когда интерпретатор обходит это дерево.

В этом задании ты воспроизведёшь эту архитектуру на JS/TS, шаг за шагом, на одном сквозном примере (программа, которая спрашивает имя и возраст, потом подтягивает приветствие из API):

  1. Опишешь DSL, набор инструкций (ReadLine, WriteLine, Fetch)
  2. Реализуешь bind (>>=), композицию инструкций с передачей результата
  3. Напишешь интерпретатор, единственное место, где происходят side effects
  4. Покажешь, что одну и ту же программу можно запустить в разных “мирах” (browser / Node / test)
  5. (★) Добавишь обработку ошибок, do-notation через генераторы и Freer encoding

Сквозной пример

Через все части прокатимся на одной и той же программе. Сначала она тривиальная: спросить имя, поздороваться. К Части 4 у неё появится второй вопрос (возраст). К Части 7, сетевой вызов за подписью дня. К Части 8 та же программа будет переписана через do-notation на генераторах. К Части 9, обрастёт счётчиком на IORef. К Части 10, обзаведётся типизированным каналом ошибок. На каждом шаге одна и та же бизнес-логика, разные представления. Контраст между ними и есть главный вывод: программа, как данные. Меняешь представление, смысл остаётся прежним.


Главное правило, которое легко нарушить

IO<A> это значение, а не функция. Программа на этом DSL, обычное значение, лежащее в переменной. Не () => value, не () => IO<A>, не thunk. Просто значение.

const greeting: IO<void> = ...        // ✓ значение программы
const greeting = (): IO<void> => ...  // ✗ thunk поверх IO, лишний слой лени

Сравни с Haskell: greeting :: IO () это значение. Используется как runIO greeting, без (). Если ловишь себя на () => IO<X>, ты не уловил суть. IO<X> уже отложенное вычисление, второй слой обёртки не нужен и вреден: он шепчет “вдруг что-то выполнится при импорте”, а смысл всей конструкции в обратном, ничего не выполняется, пока не пришёл интерпретатор.

Это правило держим в голове до самого конца, особенно в Части 8 (doIO).


Часть 0. Разминка: что не так с этим кодом?

Тот же сквозной пример, но в наивной императивной форме. Прочитай и поймай нос:

const main = () => {
  const name = readLine("What is your name?");
  writeLine(`Hello, ${name}!`);
  const data = fetch("https://api.example.com/greet");
  writeLine(data);
};

Вопрос: почему этот код не может быть чистой функцией? Что мешает тестировать его без реального stdin и сети?

Подсказка: подумай, в какой момент происходят side effects и можно ли подменить реализацию readLine/fetch, не меняя сам main.

Ответ, который мы будем строить дальше: разделить описание и выполнение. main должен возвращать структуру данных, описывающую “что сделать”, а отдельный интерпретатор пусть решает “как именно”: через реальный stdin или через тестовый mock.


Часть 1. DSL, опиши свой язык

Определи тип IO<A>, tagged union, описывающий вычисление, которое когда-нибудь вернёт значение типа A.

Начни с трёх инструкций:

ИнструкцияАналог в HaskellЧто делает
Pure(a)pure a / return aЗавершает вычисление, возвращая a
ReadLine(next)getLine >>= nextЧитает строку, передаёт результат в next
WriteLine(text, next)putStrLn s >> nextПечатает строку, продолжает next

Обрати внимание на разницу:

  • ReadLine.next это функция (string) => IO<A>, потому что read возвращает значение
  • WriteLine.next это просто IO<A>, потому что write ничего не возвращает

Это именно то, как работает >>= в Haskell: getLine >>= \name -> ..., лямбда получает результат. По сути next это continuation, остаток программы после этой инструкции.

Проверь себя, попробуй вручную собрать первый шаг сквозного примера:

// Эта структура, ЧИСТЫЕ ДАННЫЕ, ноль side effects
const greeting: IO<void> = {
  tag: "writeLine",
  text: "What is your name?",
  next: {
    tag: "readLine",
    next: (name) => ({
      tag: "writeLine",
      text: `Hello, ${name}!`,
      next: { tag: "pure", value: undefined }
    })
  }
};

Вопрос: greeting это значение. Его можно положить в переменную, передать в функцию, сериализовать в JSON (если бы не лямбда внутри). Что произойдёт, когда мы его создадим? Правильный ответ, ничего: ни одного байта в stdout, ни одного байта из stdin. Дерево лежит в памяти и ждёт интерпретатора.


Часть 2. Конструкторы, умные обёртки

Писать вложенные объекты вручную, больно. Напиши конструкторы:

const pure = <A>(value: A): IO<A> => ???

const readLine: IO<string> = ???

const writeLine = (text: string): IO<void> => ???

Подсказка: каждый конструктор создаёт “минимальную” инструкцию, у которой next просто оборачивает результат в Pure. Обрати внимание, что readLine это значение, а не функция. Как getLine в Haskell, это IO String, а не () -> IO String.

С конструкторами тот же greeting собирается так (но без bind пока, поэтому всё ещё вложенно):

const greeting: IO<void> = writeLineThen(
  "What is your name?",
  readLineThen((name) => writeLine(`Hello, ${name}!`))
);

writeLineThen и readLineThen, временные хелперы, в Части 3 их заменит универсальный bind. Но даже с этим хаком уже видно: код стал короче, форма дерева, та же.


Часть 3. bind, сердце монады

bind (он же >>=, он же chain, он же flatMap) это то, что делает IO монадой.

bind : IO<A> -> (A -> IO<B>) -> IO<B>

По-русски: “возьми вычисление, которое вернёт A, и функцию, которая из A сделает новое вычисление IO<B>. Склей их в одно IO<B>”.

bind не выполняет эффекты. Он рекурсивно перестраивает дерево, “приклеивая” продолжение f к каждому листу Pure(a):

bind(Pure(a),            f) = f(a)
bind(ReadLine(next),     f) = ReadLine(x => bind(next(x), f))
bind(WriteLine(t, next), f) = WriteLine(t, bind(next, f))

Реализуй:

const bind = <A, B>(io: IO<A>, f: (a: A) => IO<B>): IO<B> => {
  switch (io.tag) {
    case "pure":
      return ???
    case "readLine":
      return ???
    case "writeLine":
      return ???
  }
};

Подсказка: для Pure, подставляешь значение в f. Для остальных, создаёшь такой же узел, но “протаскиваешь” f глубже через рекурсивный вызов bind.

Бонус: выведи из bind ещё две операции:

// map (fmap): IO<A> -> (A -> B) -> IO<B>
const map = <A, B>(io: IO<A>, f: (a: A) => B): IO<B> => ???

// then (>>): IO<A> -> IO<B> -> IO<B>
// "сделай первое, выброси результат, сделай второе"
const then = <A, B>(first: IO<A>, second: IO<B>): IO<B> => ???

map это инстанс Functor: операция, которая применяет чистую функцию к результату вычисления, не трогая структуру эффектов. then это упрощённый bind, для случая, когда тебя не интересует значение из первого шага (как >> в Haskell vs >>=).

Замечание про сложность. Твоя реализация будет работать, но обрати внимание: bind(WriteLine(t, next), f) создаёт новый узел и рекурсивно идёт в next. Это значит, что bind(bind(bind(a, f), g), h), left-associated цепочка, переобходит дерево квадратичное число раз. Подержи это в голове, в Части 12 разберём, что с этим делать.

Замечание про стек. bind рекурсивен. Для глубоких чистых цепочек (bind(pure(0), x => bind(pure(x+1), ...)) × 100k) словишь stack overflow ещё на этапе построения дерева, до всякой интерпретации. На реальных IO-программах это редко критично, между эффектами стек размотается, но trampoline-safe эту реализацию не назвать.


Часть 4. Напиши программу

Развиваем сквозной пример: поздороваться, потом спросить возраст, потом подтвердить. Используя bind, pure, readLine, writeLine, then, опиши:

  1. Напечатай "What is your name?"
  2. Прочитай строку -> name
  3. Напечатай "Hello, {name}! How old are you?"
  4. Прочитай строку -> age
  5. Напечатай "Wow, {name}, {age} is a great age!"
const myProgram: IO<void> = ???

Заметь: myProgram это значение, лежащее в const. Не функция. Никаких () => .... Та же программа из Части 1, только теперь содержит два чтения подряд, а вложенные объекты собирает за тебя bind. На уровне типа это всё ещё IO<void>, описание дерева в памяти.

Сравни с Haskell do-notation:

myProgram :: IO ()
myProgram = do
  putStrLn "What is your name?"
  name <- getLine
  putStrLn ("Hello, " ++ name ++ "! How old are you?")
  age <- getLine
  putStrLn ("Wow, " ++ name ++ ", " ++ age ++ " is a great age!")

Каждая строка x <- action это bind(action, (x) => ...). Каждая строка без <- это then(action, ...). Do-notation, синтаксический сахар над bind. К этому виду мы вернёмся в Части 8 уже на JS-генераторах.


Часть 5. Интерпретатор, единственное нечистое место

В Haskell main :: IO () это просто значение. GHC RTS берёт его и интерпретирует: обходит дерево, выполняя реальные эффекты. Напиши свой “RTS”.

Реализуй runIO, интерпретатор, который принимает IO<A> и “мир” (набор реальных реализаций эффектов):

interface World {
  readLine: () => Promise<string>;
  writeLine: (s: string) => Promise<void>;
}

const runIO = async <A>(io: IO<A>, world: World): Promise<A> => {
  let current: IO<any> = io;

  while (true) {
    switch (current.tag) {
      case "pure":
        return current.value;
      case "readLine":
        ???
      case "writeLine":
        ???
    }
  }
};

Подсказка: для каждого тега, выполни реальный эффект через world, получи результат, подставь его в next, присвой в current. Цикл while(true) вместо рекурсии, это trampoline для stack safety.

Структурно интерпретатор это fold по AST: каждый тег маппится в реальный эффект, результат подставляется в continuation, цикл крутится дальше.


Сердце монады и интерпретатор стоит написать не в уме, а в редакторе: мир для проверки уже готов, от тебя нужны bind, map, then и runIO.

Часть 6. Разные миры

Напиши три реализации World:

1. Браузерный мир, prompt для ввода, console.log для вывода.

2. Node.js мир, readline.createInterface для ввода, console.log для вывода.

3. Тестовый мир, принимает массив строк как “ввод”, собирает вывод в массив. Никаких side effects.

Важно про тесты. Тестовый мир должен падать на незамоканных вызовах, а не возвращать ''. Mock-as-default-empty, антипаттерн: тест зелёный там, где должен быть красный, потому что программа молча получила пустую строку. Базовое правило mocks, fail loud.

Проверь тестовым миром:

const test = async () => {
  const world = testWorld(["Alice", "30"]);
  await runIO(myProgram, world);  // myProgram, без скобок!
  console.assert(world.output[0] === "What is your name?");
  console.assert(world.output[1] === "Hello, Alice! How old are you?");
  console.assert(world.output[2] === "Wow, Alice, 30 is a great age!");
  console.log("All tests passed!");
};

Ключевой момент: одна и та же программа myProgram работает в браузере, в Node.js и в тестах, без единого изменения. Программа не знает, откуда берутся строки и куда идёт вывод. Это и есть dependency inversion в чистом виде: зависимость не на конкретный stdin, а на интерфейс World.


Часть 7. Расширение, Fetch

Развиваем сквозной пример. Хочется, чтобы перед прощанием программа сходила в API за персонализированным приветствием дня. Добавь инструкцию Fetch в DSL:

| { tag: "fetch"; url: string; options?: RequestInit; next: (body: string) => IO<A> }

Что нужно обновить:

  1. Конструктор fetchUrl
  2. Кейс в bind
  3. Кейс в runIO
  4. Поле fetch в World
  5. Мок в тестовом мире

Расширим myProgram: после возраста сделаем fetch и напечатаем тело ответа:

const myProgram: IO<void> =
  bind(writeLine("What is your name?"), () =>
  bind(readLine, (name) =>
  bind(writeLine(`Hello, ${name}! How old are you?`), () =>
  bind(readLine, (age) =>
  bind(writeLine("Loading greeting of the day..."), () =>
  bind(fetchUrl("https://httpbin.org/uuid"), (body) =>
    writeLine(`Wow, ${name}, ${age}! Today's lucky token: ${body}`)
  ))))));

К этому моменту вложенность уже больно бьёт по глазам. Это нормально, в Части 8 рефакторнём её через генераторы. А сейчас обрати внимание на главное: логика та же, новый эффект не потребовал переписать ни одной существующей строки в myProgram, кроме встраивания нового шага.

Замечание про масштабируемость. Заметил, что добавление одной инструкции потребовало правок в пяти местах? Это нарушение принципа “открыт для расширения, закрыт для модификации”: ядро языка (IO, bind) знает обо всех инструкциях. В Части 11 покажу, как это разделить, чтобы новый эффект добавлялся только новым вариантом в одном объединении, без правок bind и IO.


Часть 8 ★. Do-notation через генераторы

Вложенные bind из Части 7 уродливы. В JS есть генераторы, они позволяют записать ту же программу почти как do-notation. Перепишем myProgram так, чтобы он читался как обычный императивный код.

8.1. Наивная версия

type IOGen<A> = Generator<IO<unknown>, A, unknown>;

const doIO = <A>(genFn: () => IOGen<A>): IO<A> => ???

Подсказка: вызови gen.next(value), получи result. Если result.done, верни pure(result.value). Иначе, bind(result.value, ...) с рекурсивным шагом. doIO принимает фабрику () => IOGen, а не сам генератор, потому что генераторы мутабельны и одноразовые, фабрика создаёт свежий генератор на каждый запуск интерпретатора.

Сразу как doIO готов, тот же myProgram из Части 7 преобразуется в линейный текст:

const myProgram = doIO(function* () {
  yield writeLine('What is your name?')
  const name = (yield readLine) as string
  yield writeLine(`Hello, ${name}! How old are you?`)
  const age = (yield readLine) as string
  yield writeLine('Loading greeting of the day...')
  const body = (yield fetchUrl('https://httpbin.org/uuid')) as string
  yield writeLine(`Wow, ${name}, ${age}! Today's lucky token: ${body}`)
})

Семь строк вместо лесенки bind. Уродливые as string уберём в 8.3 и 8.4.

8.2. Главное: doIO возвращает IO-значение, а не функцию

Самое частое место для ошибки. Сначала правильный вариант:

// ✓ ПРАВИЛЬНО, myProgram это IO<void>, обычное значение
const myProgram = doIO(function* () {
  yield writeLine('what is your name?')
  const name = yield* _(readLine)
  yield writeLine(`hello, ${name}`)
})

await runIO(myProgram, world)  // используется как значение
// ✗ НЕПРАВИЛЬНО, это () => IO<void>, лишний thunk
const myProgram = (): IO<void> => doIO(function* () { ... })

await runIO(myProgram(), world)  // приходится звать как функцию

В чём разница? В первом варианте myProgram это программа. Во втором, функция, которая создаёт программу. Лишняя обёртка ломает главное обещание задания: “построение IO чистое, побочных эффектов нет, значение можно положить в переменную и таскать сколько угодно”. В Haskell тип у программы myProgram :: IO (), а не myProgram :: () -> IO (). Никакой разницы.

Если ловишь себя на () => IO<X>, спроси: зачем мне второй слой лени? Ответа обычно нет.

8.3. Адаптер, избавляемся от as string

В наивной версии есть проблема. У IOGen<A> = Generator<IO<unknown>, A, unknown> третий параметр типа (тип того, что возвращает gen.next(value)) равен unknown. Любой yield отдаёт unknown, и приходится кастовать:

const name = (yield readLine) as string  // ✗ некрасиво и небезопасно

Это структурное ограничение TypeScript: генераторы не type-aligned, тип yield-выражения не зависит от того, что именно ты yield-ишь.

Решение, паттерн адаптера из Effect-TS. Идея: вместо прямого yield io используем функцию-обёртку _(io), которая делает yield* и возвращает уже типизированное значение:

type Adapter = <A>(io: IO<A>) => Generator<IO<A>, A, A>;

function* _<A>(io: IO<A>): Generator<IO<A>, A, A> {
  return (yield io) as A;  // каст спрятан внутри адаптера
}

Теперь тот же myProgram:

const myProgram = doIO(function* () {
  yield* _(writeLine('What is your name?'))
  const name = yield* _(readLine)                 // name: string, без каста!
  yield* _(writeLine(`Hello, ${name}! How old are you?`))
  const age = yield* _(readLine)
  const body = yield* _(fetchUrl('https://httpbin.org/uuid'))
  yield* _(writeLine(`Wow, ${name}, ${age}! Token: ${body}`))
})

yield* (delegating yield) разворачивает вложенный генератор и отдаёт его возвращаемое значение уже с правильным типом. Каст as A остался, но спрятан внутри _, пользователь его не видит. Это и есть трюк Effect-TS.

Честная оговорка. TypeScript не умеет в полностью типизированную do-notation. fp-ts забил и кастит на месте. Effect-TS раньше использовал адаптер. В свежих версиях обходятся без него, об этом в 8.4.

8.4. Без адаптера: трюк Effect-TS с Symbol.iterator

Пользователи стиля _(io) жаловались: лишняя сущность в API, её нужно объяснять, документировать, не забывать пробрасывать во вспомогательные функции. В свежих версиях Effect-TS _ исчез, теперь пишут просто:

const elapsed = <A, E, R>(self: Effect<A, E, R>): Effect<A, E, R> =>
  Effect.gen(function* () {
    const startMillis = yield* now      // тип A корректно выводится
    const result = yield* self          // тип A корректно выводится
    const endMillis = yield* now
    console.log(`Elapsed: ${endMillis - startMillis}`)
    return result
  })

Никакого _(...), никаких as. Как они это сделали?

Главный трюк: сам Effect сделали Iterable. Тогда yield* (делегирующий yield) идёт прямо к нему, а TypeScript вытаскивает тип результата из объявления итератора.

Вспомни сигнатуру: Generator<TYield, TReturn, TNext>. У yield* тип результата, это TReturn итератора, к которому делегируем. Effect-TS объявляет:

interface Effect<out A, out E = never, out R = never> {
  [Symbol.iterator](): EffectIterator<Effect<A, E, R>>
}

interface EffectIterator<T extends Effect<any, any, any>> {
  next(...args: ReadonlyArray<any>): IteratorResult<
    T,                // TYield, что летит наружу к Effect.gen интерпретатору
    Effect.Success<T> // TReturn, это и есть A, тип результата эффекта
  >
}

Когда ты пишешь yield* self, TypeScript рассуждает так: “self имеет тип Effect<A, E, R>, у его итератора TReturn = A, значит yield* self тоже имеет тип A”. Каст спрятан внутри итератора, тип вытекает из его объявления, пользователь ничего не кастует руками.

Как это работает в рантайме. [Symbol.iterator] возвращает одноразовый генератор: один раз отдаёт через yield сам self, потом завершается тем значением, которое ему передадут через next():

class SingleShotGen<T, A> implements Iterator<T, A> {
  private done = false
  constructor(private readonly value: T) {}

  next(input: A): IteratorResult<T, A> {
    if (this.done) return { done: true, value: input }
    this.done = true
    return { done: false, value: this.value }
  }
}

// Effect.prototype получает Symbol.iterator:
Effect.prototype[Symbol.iterator] = function () {
  return new SingleShotGen(this)
}

Что происходит на yield* self:

  1. Делегирующий генератор зовёт self[Symbol.iterator]() -> SingleShotGen
  2. Первый .next(undefined) -> { done: false, value: self }
  3. Это значение всплывает наружу, в интерпретатор Effect.gen
  4. Effect.gen видит эффект, выполняет его, получает результат типа A
  5. Передаёт A обратно через внешний gen.next(a)
  6. SingleShotGen.next(a) возвращает { done: true, value: a }
  7. yield* в твоём коде разворачивает это в a с типом A

Поведение в рантайме точно такое же, как с адаптером _(io): тот тоже оборачивал эффект, отдавал его через yield, забирал ответ. Просто теперь обёрткой служит сам Effect через Symbol.iterator.

Историческая сноска про обёртку. В третьей версии Effect итератор отдавал наружу не сам эффект, а непрозрачный newtype YieldWrap<T> с фантомным полем. Причина была в вариантности: собирая объединение всех отданных через yield эффектов (особенно союз ошибок E1 | E2 | E3), TypeScript схлопывал его неправильно, и обёртка заставляла считать каждый эффект самостоятельным кирпичом объединения. В четвёртой версии обёртку убрали: вывод типов переделали так, что эффекты и другие yieldable-значения летят через итератор напрямую. Приём стоит знать, он пригодится в любом своём DSL на генераторах.

class YieldWrap<T> {
  readonly _Y!: () => T   // phantom для invariance
  constructor(readonly value: T) {}
}

Перенесём приём в наш IO. Обёртку брать не будем, у нас один тип эффекта и вариантность не кусается:

// Каждый smart-конструктор делает результирующий объект Iterable.
// Удобнее всего сделать общую обёртку:
const mkIO = <A>(io: IO<A>): IO<A> => {
  ;(io as any)[Symbol.iterator] = function* () {
    return (yield this) as A   // каст спрятан здесь
  }
  return io
}

const pure = <A>(value: A): IO<A> => mkIO({ tag: 'pure', value })
const readLine: IO<string> = mkIO({ tag: 'readLine', next: pure })
const writeLine = (text: string): IO<void> =>
  mkIO({ tag: 'writeLine', text, next: pure(undefined) })
// ... и так для каждого конструктора

doIO забирает отданный эффект и связывает его с продолжением:

type IOGen<A> = Generator<IO<any>, A, any>

const doIO = <A>(genFn: () => IOGen<A>): IO<A> => {
  const gen = genFn()
  const step = (value: unknown): IO<A> => {
    const result = gen.next(value)
    if (result.done) return pure(result.value)
    return bind(result.value, step)   // result.value это отданный IO
  }
  return step(undefined)
}

Использование, без _, без as. Тот же myProgram в финальной идиоматичной форме:

const myProgram = doIO(function* () {
  yield* writeLine('What is your name?')
  const name = yield* readLine                                // name: string, автоматически
  yield* writeLine(`Hello, ${name}! How old are you?`)
  const age = yield* readLine                                 // age: string
  yield* writeLine('Loading greeting of the day...')
  const body = yield* fetchUrl('https://httpbin.org/uuid')    // body: string
  yield* writeLine(`Wow, ${name}, ${age}! Token: ${body}`)
})

Это и есть do-notation промышленного уровня на TypeScript. Никаких функций-адаптеров в API, типы выводятся корректно, единственный каст спрятан внутри Symbol.iterator и пользователю не виден.

Сравни три варианта:

ПодходКак пишетсяМинус
Наивный (as A снаружи)const x = (yield io) as AКаст в коде пользователя
Адаптер (_(io))const x = yield* _(io)Лишняя сущность в API
Symbol.iterator (Effect-TS)const x = yield* ioСложнее реализация

8.5. Однократность генератора, suspend

В реализациях doIO из 8.1 и 8.4 спрятан тонкий баг. Посмотри ещё раз:

const doIO = <A>(genFn: () => IOGen<A>): IO<A> => {
  const gen = genFn()                            // ← вызвали один раз, при сборке IO
  const step = (value: unknown): IO<A> => {
    const result = gen.next(value)
    if (result.done) return pure(result.value)
    return bind(result.value.value, step)
  }
  return step(undefined)
}

genFn() зовётся один раз: в момент построения IO, а не в момент его исполнения. Возвращённое значение замыкается на этот единственный экземпляр gen. Первый runIO(myProgram, world) пройдёт от начала до конца и оставит gen в done: true. Второй прогон того же myProgram против другого мира войдёт в первую инструкцию (она уже зашита в дерево bind), а на следующем шаге step(value) получит от исчерпанного генератора { done: true } и тихо выйдет через pure(undefined). Снаружи это выглядит так: программа “отработала”, промис разрешился, никто не упал. Но world.outputs короче, чем должен быть, ровно на длину сценария минус один шаг.

Вспомни девиз из Части 1: построение IO, чистые данные, эффекты, только в интерпретаторе. Здесь мы его дважды нарушили. Узлы дерева, ок, eagerly строить их можно. Но состояние генератора оказалось привязано к самому дереву намертво. После первого прохода интерпретатор обходит уже не описание программы, а её останки.

Лекарство, новый узел Suspend:

type IO<A> =
  | { tag: 'pure'; value: A }
  | { tag: 'readLine'; next: (s: string) => IO<A> }
  | { tag: 'writeLine'; text: string; next: IO<A> }
  | { tag: 'fetch'; url: string; options?: RequestInit; next: (body: string) => IO<A> }
  | { tag: 'suspend'; thunk: () => IO<A> }   // ← новое

Suspend(thunk), обещание “когда дойдёшь сюда, дёрни функцию и продолжай уже её результатом”. Конструктор тривиален:

const suspend = <A>(thunk: () => IO<A>): IO<A> => ({ tag: 'suspend', thunk })

Один новый кейс в bind (приклеиваем f уже к свежему результату внутри будущего thunk-а):

case 'suspend':
  return suspend(() => bind(io.thunk(), f))

Один новый кейс в runIO:

case 'suspend':
  current = current.thunk()
  break

И сам doIO переезжает внутрь suspend. Генератор теперь создаётся в момент интерпретации, а не сборки:

const doIO = <A>(genFn: () => IOGen<A>): IO<A> =>
  suspend(() => {
    const gen = genFn()
    const step = (value: unknown): IO<A> => {
      const result = gen.next(value)
      if (result.done) return pure(result.value)
      return bind(result.value.value, step)
    }
    return step(undefined)
  })

Снаружи ничего не изменилось: тот же const myProgram = doIO(function* () { ... }), тот же runIO(myProgram, world). Внутри второй прогон стартует со свежего генератора, программа стала по-настоящему переиспользуемой. Одно описание, любое количество интерпретаций против разных миров.

Перекличка с Effect-TS. В Effect.gen логически тот же приём: Effect.suspend (и его тонкая обёртка Effect.sync) откладывают сборку поддерева до момента, когда Fiber к нему дойдёт. Когда полезешь читать исходники Effect, узнаешь там тот же suspend. Идея общая: ленивая граница между описанием и исполнением.

Трассировка: пробежим механизм шаг за шагом

Дальше, как в debugger. На каждом шаге подсвечена конкретная строка doIO, bind или runIO, а справа видно, что эта строка только что вернула и в какую функцию мы проваливаемся дальше. В этой версии doIO каждое продолжение тоже завёрнуто в маленький suspend(() => step(v)). Внешний suspend решает проблему свежего generator-а, как в коде выше, а внутренний переносит саму рекурсию из JS-стека в дерево IO. Тот же приём, который сделает doIO стэк-безопасным, об этом сразу в 8.6.

8.6. Безопасность стека в doIO

Внутри doIO ты вызываешь bind(result.value, step) рекурсивно. На длинных генераторах это рискует переполнить стек. Для учебных программ на пять шагов всё в порядке, но если играешь всерьёз, копай в сторону Codensity transform или техники Reflection without remorse. Подробный разбор обоих подходов, с кодом и графиками сложности, ждёт тебя в Части 12. Для текущей части достаточно знать, что проблема существует и у неё есть стандартные лекарства.


дальше после входа

Урок целиком открыт всем, кто вошёл. Войди по своему email, чтобы читать дальше.

Вход пока по приглашению. Если хочешь учиться, напиши Vassiliy.