Раздел 46 · Effect: Тихая Академия

Result: дорога в два пути

middle~30 мин

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

Журнал башни приходит с провода строками вида sent 41 1030: вид записи, номер кадра, момент. Ринцвальд разбирает строку и, если вид незнакомый, бросает исключение. На одной кривой строке ночью упал весь отчёт смены.

В прошлом уроке коробка научилась быть пустой, и пустота перестала путаться с нулём. Пустая коробка честно говорит «нет», но не говорит почему. Этот урок о коробке, в которой лежит либо значение, либо причина, и о том, как складывать шаги, каждый из которых может не удаться, не теряя ни значений, ни причин.

Сигнатура, которая врёт

Вот разбор Ринцвальда:

import type { Entry } from './journal';

const parseLine = (line: string): Entry => {
  const [kind, frame, at] = line.trim().split(/\s+/);
  if (kind !== 'sent' && kind !== 'acked') throw new Error(`плохая строка: ${line}`);
  return { kind, frame: Number(frame), at: Number(at) };
};

const lines = ['sent 41 1030', 'snet 42 1040', 'acked 41 1100'];
console.log(lines.map(parseLine));
Error: плохая строка: snet 42 1040
    at parseLine (throw.ts:5:50)

У этой функции два провала, громкий и тихий. Громкий ты видишь: опечатка snet бросила исключение, и map не дошёл до третьей строки. В сигнатуре (line: string) => Entry этого не видно, поэтому никто из вызывающих не обязан его ловить, и никто не ловит. Тихий провал хуже: parseLine('sent сорок 1030') вернёт { kind: 'sent', frame: NaN, at: 1030 }, запись с номером кадра, которого не бывает, и она спокойно поедет дальше по отчёту.

Исключение в TypeScript невидимо для типов: throw может стоять в любой функции, и компилятор не спросит, кто его поймает. Можно было бы ловить всё подряд, но это catch {} из урока 02, и он уже однажды спрятал ошибки Магистрали.

Коробка с причиной

Матушка предлагает ту же коробку, что в прошлом уроке, только к пустой коробке приложена записка: почему пусто.

export type Success<A> = { readonly _tag: 'Success'; readonly success: A };
export type Failure<E> = { readonly _tag: 'Failure'; readonly failure: E };
export type Result<A, E = never> = Success<A> | Failure<E>;

export const succeed = <A, E = never>(success: A): Result<A, E> => ({ _tag: 'Success', success });
export const fail = <E, A = never>(failure: E): Result<A, E> => ({ _tag: 'Failure', failure });

Это Result: снова сумма из двух вариантов, размеченная по _tag. Второй параметр E это тип причины, и он стоит прямо в сигнатуре. Функция (line: string) => Result<Entry, EntryError> больше не врёт: по ней видно, что разбор может не удаться и почему.

E = never по умолчанию значит «ошибок нет». Result<number> это Result<number, never>, и вариант Failure<never> пуст: создать его нельзя, потому что у never нет значений. Это тот же ноль для сложения из урока 23.

Дорога в два пути

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

Отметь шаг validate падающим и пройди дорогу. После него поезд уходит на нижний путь, а save и notify пропускаются: их никто не позвал. Переключатель повтора пока не трогай, повторы появятся в разделе про время.

На языке кода стрелки это две функции. map для шага, который не может не удаться: он меняет значение на верхнем пути, а нижний пропускает как есть. flatMap для шага, который сам отвечает Result: он и есть стрелка, которая может перевести вниз.

/** Следующий шаг дороги; ошибки двух шагов складываются в объединение. */
export const flatMap: {
  <A, B, E2>(f: (a: A) => Result<B, E2>): <E>(self: Result<A, E>) => Result<B, E | E2>;
  <A, E, B, E2>(self: Result<A, E>, f: (a: A) => Result<B, E2>): Result<B, E | E2>;
} = dual(
  2,
  <A, E, B, E2>(self: Result<A, E>, f: (a: A) => Result<B, E2>): Result<B, E | E2> =>
    self._tag === 'Success' ? f(self.success) : self,
);

Самое интересное в сигнатуре это E | E2. Первый шаг мог упасть с E, второй с E2, значит, вся дорога может упасть с любой из двух. Ты видел это в уроке 11, когда собирал свою ленивую задачу, и в уроке 13, где выяснилось, почему ошибки складываются в объединение. Теперь это работает на двух коробках:

type Small = { readonly _tag: 'Small'; readonly n: number };
type Odd = { readonly _tag: 'Odd'; readonly n: number };

const positive = (n: number): Result.Result<number, Small> =>
  n > 0 ? Result.succeed(n) : Result.fail({ _tag: 'Small', n });
const half = (n: number): Result.Result<number, Odd> =>
  n % 2 === 0 ? Result.succeed(n / 2) : Result.fail({ _tag: 'Odd', n });

const steps = (n: number) =>
  pipe(
    positive(n),
    Result.flatMap(half),
    Result.map((h) => `половина: ${h}`),
  ); // Result<string, Small | Odd>
steps(4)  → { _tag: 'Success', success: 'половина: 2' }
steps(3)  → { _tag: 'Failure', failure: { _tag: 'Odd', n: 3 } }
steps(-2) → { _tag: 'Failure', failure: { _tag: 'Small', n: -2 } }

Обрати внимание на steps(-2). positive перевёл поезд вниз, и half его не увидел: в ошибке Small, а не Odd. Первая ошибка проезжает до конца, следующие шаги её не перезаписывают.

У дороги есть ещё четыре приспособления:

  • mapError это map для нижнего пути: меняет причину и не трогает успех. Им ошибки разных шагов приводят к одному виду.
  • tap заглядывает в успех и отдаёт тот же результат, не меняя его. Им пишут в журнал на краю программы; внутри вычислений он не нужен.
  • match с полями onFailure и onSuccess сводит дорогу в одно значение, например в текст отчёта.
  • fromOption переводит пустую коробку из прошлого урока в ошибку с причиной, которую ты называешь сам.

Разбор строки журнала

Теперь разбор Ринцвальда без единого throw. У ошибки три вида, по тегам:

export type EntryError =
  | { readonly _tag: 'Garbled'; readonly line: string }
  | { readonly _tag: 'BadKind'; readonly kind: string }
  | { readonly _tag: 'BadNumber'; readonly field: 'frame' | 'at'; readonly raw: string };

const parseKind = (kind: string): Result.Result<Entry['kind'], EntryError> =>
  kind === 'sent' || kind === 'acked' ? Result.succeed(kind) : Result.fail({ _tag: 'BadKind', kind });

const parseNumber = (field: 'frame' | 'at', raw: string): Result.Result<number, EntryError> =>
  /^\d+$/.test(raw) ? Result.succeed(Number(raw)) : Result.fail({ _tag: 'BadNumber', field, raw });

export const parseEntry = (line: string): Result.Result<Entry, EntryError> => {
  const parts = line.trim().split(/\s+/);
  const [kind = '', frame = '', at = ''] = parts;
  if (parts.length !== 3) return Result.fail({ _tag: 'Garbled', line });
  return pipe(
    parseKind(kind),
    Result.flatMap((kind) =>
      pipe(
        parseNumber('frame', frame),
        Result.map((frame) => ({ kind, frame })),
      ),
    ),
    Result.flatMap(({ kind, frame }) =>
      pipe(
        parseNumber('at', at),
        Result.map((at): Entry => ({ kind, frame, at })),
      ),
    ),
  );
};
'sent 41 1030'    → Success { kind: 'sent', frame: 41, at: 1030 }
'snet 42 1040'    → Failure { _tag: 'BadKind', kind: 'snet' }
'sent сорок 1030' → Failure { _tag: 'BadNumber', field: 'frame', raw: 'сорок' }
'acked 41'        → Failure { _tag: 'Garbled', line: 'acked 41' }

Оба провала Ринцвальда закрыты. Опечатка snet теперь значение, а не исключение, и отчёт решает, что с ним делать. сорок больше не превращается в NaN: parseNumber проверяет цифры раньше, чем Number успеет промолчать. Порядок проверок задан дорогой: сначала вид, потом номер, потом момент, и первая ошибка останавливает разбор.

Лестница вложенных pipe выглядит громоздко: второй шаг должен помнить kind из первого, третий оба. С этой лестницей ещё придётся разобраться, а пока она честно делает своё дело.

Перевод с Ук

Ринцвальд объявил, что разбор падает только на «мало», и забыл, что half тоже умеет падать:

const halved: Result.Result<number, Small> = pipe(positive(4), Result.flatMap(half));
uk.ts(12,7): error TS2322: Type 'Result<number, Small | Odd>' is not assignable to type 'Result<number, Small>'.
  Type 'Failure<Small | Odd>' is not assignable to type 'Result<number, Small>'.
    Type 'Failure<Small | Odd>' is not assignable to type 'Failure<Small>'.
      Type 'Small | Odd' is not assignable to type 'Small'.
        Type 'Odd' is not assignable to type 'Small'.
          Types of property '_tag' are incompatible.
            Type '"Odd"' is not assignable to type '"Small"'.

Семь строк, и читаются они снизу, как учил урок 04. Самая нижняя: тег "Odd" не помещается в тег "Small". Строкой выше то же для целых типов: Odd не Small. Ещё выше: объединение Small | Odd не помещается в Small, потому что в нём есть Odd. Дальше компилятор поднимается по коробкам: Failure<Small | Odd> не Failure<Small>, значит, и весь Result не подходит. Перевод в одну фразу: «дорога может упасть с Odd, а ты обещал только Small». Чинится честностью: объяви Result<number, Small | Odd> или переведи Odd в Small через mapError, если это одна и та же беда.

Это и есть главное отличие от throw. Забыть про ошибку шага больше нельзя: компилятор назовёт её по тегу.

Куда придёшь

В Effect 4 Result устроен так же: теги Success и Failure, поля success и failure, Result<A, E = never>, функции в двух видах.

import { pipe, Result } from 'effect';

const positive = (n: number): Result.Result<number, 'мало'> =>
  n > 0 ? Result.succeed(n) : Result.fail('мало');
const half = (n: number): Result.Result<number, 'нечётное'> =>
  n % 2 === 0 ? Result.succeed(n / 2) : Result.fail('нечётное');

const halved: Result.Result<number, 'мало' | 'нечётное'> = pipe(
  positive(4),
  Result.flatMap(half),
  Result.map((h) => h * 10),
);

У самих эффектов та же форма: Effect<A, E, R> тоже несёт тип ошибки, и ошибки шагов там тоже складываются в объединение. Только эффект ещё и ленивый, и про R в нём будет отдельный разговор.

Где тут дыра

const frame = pipe(
  Result.succeed('{"frame": 41'),
  Result.map((text): { readonly frame: number } => JSON.parse(text)),
);
SyntaxError: Expected ',' or '}' after property value in JSON at position 12 (line 1 column 13)
    at JSON.parse (<anonymous>)

map не ловит исключений. Дорога в два пути работает, пока каждый шаг сам говорит о своём провале значением. Шаг, который бросает, не переводит поезд на нижний путь, а сносит его с рельсов: исключение пролетает мимо Result и роняет всё, как в начале урока. map для шагов, которые не могут не удаться. JSON.parse может, поэтому его заворачивают в функцию, которая сама ловит исключение и возвращает Result.fail с причиной, и ставят на дорогу через flatMap. Ловить надо там, где исключение рождается, а не надеяться на рельсы.

Котёл, этап 9

Котёл учится говорить, чем кончилось вычисление. Ты напишешь модуль Result с конструкторами, проверками и дорогой целиком: map, flatMap со сложением ошибок, mapError, tap, match и fromOption. Затем журнал научится разбирать строку sent 41 1030 без единого throw, с тремя видами ошибки и строгим порядком проверок. Модуль src/Result.ts в заготовке пустой, импорт в journal.ts допишешь сам.

Практика

Дальше

Ночной журнал разобран: кривые строки стали ошибками с причинами, отчёт больше не падает. Утром Рыдкулл кладёт на стол новый журнал с провода Магистрали.

Дорога останавливается на первой ошибке, и для шагов, где следующий зависит от предыдущего, это правильно. Но строки журнала друг от друга не зависят. Как получить все ошибки сразу и когда так делать нельзя, разберём в следующем уроке.