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

Проверки и бренды

middle~20 мин

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

В прошлом уроке бланк кадра научился различать виды полей. Но у кода башни есть смысл, которого нет у строки: буква T, дефис, цифры. И у номера кадра есть смысл, которого нет у числа: он целый и больше нуля. Этот урок про две вещи: как проверить смысл при разборе и как сделать, чтобы проверку было видно в типе.

Проверка поверх вида

Любую схему можно дополнить проверками через .check(...). Для частых случаев есть готовые:

import { Schema } from 'effect';

const Code = Schema.String.check(Schema.isPattern(/^T-\d+$/, { message: 'код башни вида T-число' }));

const Id = Schema.Int.check(Schema.isGreaterThan(0, { message: 'номер кадра больше нуля' }));

Schema.isPattern сверяет строку с регулярным выражением, Schema.isGreaterThan число с границей. Их много: длина строки, диапазон, кратность, непустой массив; список на странице Filters. Второй аргумент { message } даёт тексту ошибки человеческие слова, иначе их пишет сама схема по-английски. Разбор 'Т-12' и 0:

код башни вида T-число

номер кадра больше нуля

Проверка меняет только разбор. Тип остаётся прежним: string и number.

Своя проверка

Готовой проверки на всё не хватит. Башня не шлёт кадр сама себе, и маршрут T-1 → T-1 бессмыслен. Это правило про два поля сразу, и пишется оно своей проверкой:

const Route = Schema.Tuple([Code, Code]).check(
  Schema.makeFilter(([from, to]) => from !== to || 'башня не шлёт кадр сама себе'),
);

Schema.makeFilter получает значение, которое уже прошло вид, и отвечает true, если всё в порядке, или текст ошибки. Порядок важен: сначала разбор вида, потом проверки. Поэтому внутри фильтра from и to уже строки, а не unknown.

башня не шлёт кадр сама себе

Есть и Schema.refine: проверка, которая заодно сужает тип, как предикат с is из урока 06.

const Calling = Schema.String.pipe(Schema.refine((s): s is `T-${string}` => s.startsWith('T-')));
type Calling = typeof Calling.Type; // `T-${string}`

Это удобно, когда у суженного типа есть имя в TypeScript. У кода башни его нет: «T, дефис и только цифры» шаблонная строка не выразит.

Печать

Проверили при разборе, а что дальше? Значение ушло в функцию отправки, оттуда в журнал, оттуда в отчёт. По типу string никто по дороге не отличит проверенный код от строки, которую кто-то собрал руками. Нужна метка в типе, которую нельзя получить, не пройдя проверку. Её называют брендом. Ты строил такой руками в уроке 14 на unique symbol. В Schema бренд ставит одна деталь:

export const TowerCode = Schema.String.check(
  Schema.isPattern(/^T-\d+$/, { message: 'код башни вида T-число' }),
).pipe(Schema.brand('TowerCode'));

export type TowerCode = typeof TowerCode.Type; // string & Brand<'TowerCode'>

В рантайме ничего не меняется: значение та же строка 'T-1'. Меняется тип. Получить TowerCode можно двумя путями, и оба проверяют:

  • разобрать схемой: Schema.decodeUnknownEffect(TowerCode)(input);
  • собрать конструктором: TowerCode.make('T-1') проверяет так же, как разбор, и бросает на 'Т-12'. Это для кода, который пишет сама Академия, а не для входа с провода.

Перевод с Ук

Функция отправки теперь просит код с печатью:

import { Effect, Schema } from 'effect';

const TowerCode = Schema.String.check(Schema.isPattern(/^T-\d+$/)).pipe(Schema.brand('TowerCode'));
type TowerCode = typeof TowerCode.Type;

declare const sendTo: (code: TowerCode, line: string) => Effect.Effect<void>;

export const night = sendTo('T-99', 'гроза');
tower.ts(8,29): error TS2345: Argument of type 'string' is not assignable to parameter of type 'string & Brand<"TowerCode">'.
  Type 'string' is not assignable to type 'Brand<"TowerCode">'.

Снизу: string не помещается в Brand<"TowerCode">. Строкой выше: аргумент типа string не подходит к параметру string & Brand<"TowerCode">. Пересечение значит «и то и другое сразу»: строка и бренд. У литерала 'T-99' есть только первая половина. Компилятор не знает и не может знать, что T-99 выглядит правильно: проверку видно только по печати, а печать ставит разбор. Чинится не приведением as TowerCode, которое ставит печать без проверки, а разбором:

const night = Effect.flatMap(Schema.decodeUnknownEffect(TowerCode)('T-99'), (code) => sendTo(code, 'гроза'));

Кадр с печатью

Бланк из прошлого урока остаётся как есть: старый договор не переписывают. Рядом встаёт кадр, которому можно верить:

export const FrameId = Schema.Int.check(
  Schema.isGreaterThan(0, { message: 'номер кадра больше нуля' }),
).pipe(Schema.brand('FrameId'));

export const Frame = Schema.Struct({
  ...RawFrame.fields,
  id: FrameId,
  route: Route,
  relay: Schema.optionalKey(TowerCode),
});

RawFrame.fields это объект полей схемы. Разворачиваем его и подменяем три поля: остальные четыре переписывать незачем. У Frame теперь две стороны с разными типами. typeof Frame.Type хранит route как readonly [TowerCode, TowerCode], а typeof Frame.Encoded, то, что лежит на проводе, всё ещё readonly [string, string]. Печать ставится при разборе и снимается при записи; на провод уходит обычная строка. Подробно о двух сторонах в следующем уроке.

Где тут дыра

const isTowerCode = (code: string): boolean => /^T-\d+$/.test(code);
const sendTo = (code: string, line: string) => console.log(`> ${code}: ${line}`);

const handle = (to: string) => {
  if (isTowerCode(to)) sendTo(to, 'гроза');
};

handle('T-99');
const config = { neighbour: 'Т-12' };
sendTo(config.neighbour, 'утренняя сводка');
> T-99: гроза
> Т-12: утренняя сводка

В обработчике проверка есть, и T-99 прошёл честно. А утренняя сводка пошла в обход: код соседа взяли из настроек, и sendTo его принял, потому что ей годится любая строка. Русская Т ушла на провод. Проверка, которая живёт в одном if, защищает одну дорогу. Печать защищает все: с sendTo(code: TowerCode, ...) вторая строка просто не соберётся, и код из настроек придётся сначала разобрать.

Магистраль, этап 3

Кадр получает печати: код башни, номер кадра и маршрут, где башня не шлёт сама себе. Тесты проверяют и разбор, и типы: строка без печати там, где ждут TowerCode, не должна собираться.

Практика

Глубже в архиве

Бренды на голом TypeScript и зачем они на границах программы разобраны в уроке «Надёжность и типы на границах».

Дальше

Казимир переворачивает кадр Магистрали и показывает на поле, которого в бланке пока нет.

Две стороны схемы ждут в следующем уроке.