Ринцвальд считает, сколько молчит башня. Для T-1 всё верно. Для T-9, которой на доске нет, отчёт пишет «молчит 60000 мс», и это читается как «давно молчит», а не «такой башни нет». Никто не ошибся в арифметике, ошибка в том, что отсутствие ответа превратилось в число.
В прошлом уроке тип судьбы кадра перестал пускать невозможные состояния. Здесь та же мысль для самого частого случая: значения может не быть. В TypeScript для этого есть undefined, и урок о том, почему его мало и что взять вместо.
Ноль вместо ответа
Первая версия Ринцвальда даже не собирается:
import type { Board } from './journal';
const silentFor = (board: Board, code: string, now: number): number => {
const lastAt = board[code]?.lastAt;
return now - lastAt;
};
undef.ts(5,16): error TS18048: 'lastAt' is possibly 'undefined'.
Компилятор прав: с noUncheckedIndexedAccess поиск по доске даёт Tower | undefined, и lastAt тоже может не быть. Ринцвальд чинит самым коротким способом:
const silentFor = (code: string, now: number): string =>
`${code} молчит ${now - (board[code]?.lastAt ?? 0)} мс`;
T-1 молчит 59000 мс
T-9 молчит 60000 мс
Компилятор доволен, а отчёт врёт. ?? 0 не проверил отсутствие, а спрятал его: дальше по коду ноль ничем не отличается от настоящего момента. Решение «что значит нет ответа» принято посреди вычисления, где о нём ничего не известно, и никто дальше не узнает, что оно было принято.
Неопределённость, которая схлопывается
У undefined есть беда похуже. Пусть Ринцвальд хранит моменты в Map, и башня T-4 на доске есть, но её ещё ни разу не слышали:
const heardAt = new Map<string, number | undefined>([
['T-1', 1000],
['T-4', undefined],
]);
console.log(heardAt.get('T-4'), heardAt.get('T-9'));
undefined undefined
Два разных ответа, «башня есть, но молчит с рождения» и «башни нет», слиплись в один. Тип number | undefined | undefined это тот же number | undefined: объединение не умеет вкладывать одно «нет» в другое. Чтобы их различить, нужен второй вызов has, а его легко забыть.
Коробка
Матушка предлагает держать ответ в коробке. Коробка бывает двух видов: пустая или с чем-то внутри. Это сумма из прошлого урока, размеченная по _tag:
export type None = { readonly _tag: 'None' };
export type Some<A> = { readonly _tag: 'Some'; readonly value: A };
export type Option<A> = None | Some<A>;
export const none = <A = never>(): Option<A> => ({ _tag: 'None' });
export const some = <A>(value: A): Option<A> => ({ _tag: 'Some', value });
Такой тип называют Option. Мощность у него 1 + |A|: один вариант «пусто» и столько непустых, сколько значений у A. Коробка в коробке это 1 + 1 + |A|, и два «нет» уже не слипаются: Some(None) и None разные значения. Поэтому Option<Option<number>> честно хранит «башня есть, но молчит» отдельно от «башни нет».
Из мира undefined в мир коробок ведёт fromNullable: null и undefined становятся пустой коробкой, всё остальное кладётся внутрь, в том числе 0, '' и false. Это важно: «ноль» и «ничего» больше не путаются.
export const fromNullable = <A>(value: A): Option<NonNullable<A>> =>
value === null || value === undefined ? none() : some(value);
Котёл держит Option отдельным модулем, как сам Effect, и тесты подключают его пространством имён: import * as Option from './Option'. Поэтому тип читается Option.Option<number>, а функции Option.map, Option.getOrElse. Так же выглядит код на Effect.
Работать, не открывая
Ценность коробки в том, что с её содержимым можно работать, не заглядывая внутрь. map берёт функцию для значения и применяет её внутри: если коробка полная, в ней окажется результат, если пустая, она останется пустой, а функцию никто не позовёт.
export const map: {
<A, B>(f: (a: A) => B): (self: Option<A>) => Option<B>;
<A, B>(self: Option<A>, f: (a: A) => B): Option<B>;
} = dual(
2,
<A, B>(self: Option<A>, f: (a: A) => B): Option<B> =>
self._tag === 'Some' ? some(f(self.value)) : none(),
);
Это dual из урока 22: Option.map(box, f) и pipe(box, Option.map(f)) работают оба. Теперь отчёт о молчании выглядит так:
const report = (code: string, now: number): string =>
pipe(
lastHeard(board, code),
Option.map((lastAt) => `${code} молчит ${now - lastAt} мс`),
Option.getOrElse(() => `${code} нет на доске`),
);
T-1 молчит 59000 мс
T-9 нет на доске
lastHeard отвечает коробкой. Вычитание живёт внутри map и случается только для настоящей башни. В самом конце getOrElse решает, что написать, если коробка пуста. Это решение принято один раз и там, где о нём есть что сказать: в тексте отчёта. Для двух веток сразу есть match, с полями onNone и onSome.
Коробка в коробке
Иногда функция, которую хочется применить внутри, сама отвечает коробкой. Пусть код башни приходит строкой, и сначала его надо проверить:
const tower = (input: string): Option.Option<string> =>
/^T-\d+$/.test(input) ? Option.some(input) : Option.none();
const nested = pipe(
tower('T-1'),
Option.map((code) => lastHeard(board, code)),
); // Option<Option<number>>
const flat = pipe(
tower('T-1'),
Option.flatMap((code) => lastHeard(board, code)),
); // Option<number>
map честно положил в коробку то, что вернула функция, а функция вернула коробку. Получилась коробка в коробке, и чтобы достать число, её придётся открывать дважды. flatMap делает другое: если коробка полная, отдаёт значение функции и возвращает её коробку, без обёртки. Пустая на любом шаге даёт пустую в конце. Читается так: «если код верный, найди башню; если и башня есть, вот момент».
Правило выбора простое. Функция возвращает значение, бери map. Функция возвращает коробку, бери flatMap.
Перевод с Ук
Ринцвальд торопится и лезет в коробку напрямую:
import * as Option from './Option';
const box = Option.some(1000);
console.log(box.value);
peek.ts(4,17): error TS2339: Property 'value' does not exist on type 'Option<number>'.
Property 'value' does not exist on type 'None'.
Читаем снизу. Нижняя строка называет вариант, в котором поля нет: None. Верхняя переводит это на весь тип: у Option<number> поля value нет, потому что оно есть не у всех вариантов. Компилятор не смотрит, что ты только что положил в коробку 1000: some возвращает Option<number>, а не Some<number>, и это нарочно. Коробку нельзя открыть, не спросив, полная ли она. Спросить можно через Option.isSome, через _tag в switch или, лучше всего, не открывать вовсе и работать через map, flatMap и getOrElse.
Куда придёшь
В Effect Option устроен так же: те же теги Some и None, то же поле value, те же map, flatMap, getOrElse в двух видах. Тот же отчёт на настоящем effect:
import { Option, pipe } from 'effect';
const board: Readonly<Record<string, { readonly lastAt: number }>> = { 'T-1': { lastAt: 1000 } };
const report = (code: string, now: number): string =>
pipe(
Option.fromNullishOr(board[code]),
Option.map((tower) => `${code} молчит ${now - tower.lastAt} мс`),
Option.getOrElse(() => `${code} нет на доске`),
);
Одно отличие в имени: в Effect 4 наш fromNullable называется fromNullishOr, рядом есть fromUndefinedOr и fromNullOr для случаев, когда пустым считается только одно из двух.
Где тут дыра
const silence = (code: string, now: number): number => {
const lastAt = Option.getOrElse(lastHeard(board, code), () => 0);
return now - lastAt;
};
console.log(silence('T-1', 60_000), silence('T-9', 60_000));
59000 60000
Коробка прожила одну строку. getOrElse(() => 0) это тот же ?? 0, только длиннее: пустоту опять заменили нулём посреди вычисления, и T-9 снова молчит шестьдесят секунд. Коробка помогает, пока её не открыли. Открывать стоит на краю, там, где ответ превращается в текст отчёта или в решение, а не там, где он только что появился.
Котёл, этап 8
Котёл получает свою коробку. Ты напишешь модуль Option с none, some, fromNullable, проверками isSome и isNone и четырьмя функциями в двух видах через dual: map, flatMap, getOrElse и match. Затем журнал научится отвечать коробкой на вопрос, когда башню слышали в последний раз. Модуль src/Option.ts в заготовке пустой, импорт в journal.ts допишешь сам.
Практика
Дальше
Отчёт о молчании честный: T-9 нет на доске. Ринцвальд приносит следующую беду. Журнал башни приходит с провода строками, sent 41 1030, и строка бывает битой.
Пустая коробка говорит «ничего нет», но не говорит почему. Как вернуть из разбора и запись, и причину, если записи нет, и при этом не бросать исключений, которых не видно в типе, разберём в следующем уроке.