Журнал башни приходит с провода строками вида 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 допишешь сам.
Практика
Дальше
Ночной журнал разобран: кривые строки стали ошибками с причинами, отчёт больше не падает. Утром Рыдкулл кладёт на стол новый журнал с провода Магистрали.
Дорога останавливается на первой ошибке, и для шагов, где следующий зависит от предыдущего, это правильно. Но строки журнала друг от друга не зависят. Как получить все ошибки сразу и когда так делать нельзя, разберём в следующем уроке.