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

Рекурсия и проверки с миром

middle-senior~20 мин

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

Маршрут из урока 77 это Tuple из двух строк, а из урока 80 пара с необязательным ретранслятором. Для цепочки любой длины ни кортеж, ни массив не годятся по смыслу: у перегона есть башня и, может быть, следующий перегон, который устроен так же. Схема должна ссылаться сама на себя. А вторая просьба Казимира вообще не про форму: чтобы узнать, есть ли башня, надо спросить книгу башен, то есть мир.

Цепочка

Сначала тип. Перегон это башня и, если кадр идёт дальше, следующий перегон:

export interface Hop {
  readonly tower: TowerCode;
  readonly next?: Hop;
}

Тип на себя сослаться может: TypeScript разрешает интерфейсу упоминать себя в полях. Теперь схема. Первая попытка в лоб:

export const Hop = Schema.Struct({
  tower: Schema.String,
  next: Schema.optionalKey(Hop),
});

Это не соберётся и не запустится. Hop справа ещё не объявлен: значение const читают раньше, чем оно появилось. С типами так можно, со значениями нельзя. Нужна отсрочка: не сама схема, а функция, которая отдаст её потом. Это Schema.suspend:

export const Hop = Schema.Struct({
  tower: TowerCode,
  next: Schema.optionalKey(Schema.suspend(() => Hop)),
});

Перевод с Ук

hop.ts(5,14): error TS7022: 'Hop' implicitly has type 'any' because it does not have a type annotation and is referenced directly or indirectly in its own initializer.
hop.ts(7,43): error TS7024: Function implicitly has return type 'any' because it does not have a return type annotation and is referenced directly or indirectly in one of its return expressions.

Читаем снизу вверх. Вторая ошибка про стрелку () => Hop: чтобы узнать, что она возвращает, нужно знать тип Hop. Первая про сам Hop: чтобы узнать его тип, нужно вывести тип всего выражения справа, а внутри опять стрелка. Компилятор выводит типы изнутри наружу, и здесь у него нет начала: вывод ходит по кругу. Тогда он сдаётся и пишет «неявно any», а под strict это ошибка.

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

export interface HopEncoded {
  readonly tower: string;
  readonly next?: HopEncoded;
}

export const Hop: Schema.Codec<Hop, HopEncoded> = Schema.Struct({
  tower: TowerCode,
  next: Schema.optionalKey(Schema.suspend((): Schema.Codec<Hop, HopEncoded> => Hop)),
});

Schema.Codec<Type, Encoded> это схема с двумя сторонами из урока 79. Поэтому интерфейсов два: в башне у перегона TowerCode с брендом, на проводе простая строка. Компилятору хватает подписи у константы: стрелка берёт тип оттуда. В эталоне подпись стоит и у стрелки, чтобы читатель не искал, что она возвращает.

[ 'T-1', 'T-4', 'T-9' ]
код башни вида T-число
  at ["next"]["next"]["tower"]

Цепочка из трёх башен разбирается, а битый код в третьем перегоне Schema находит и называет путь целиком. towersOf из задачи этапа выпрямляет цепочку в список.

Под капотом

Схема в Effect это дерево описаний, SchemaAST. Schema.suspend(f) кладёт в это дерево узел, в котором лежит только функция f. Пока схему собирают, f никто не зовёт, поэтому к моменту вызова Hop давно объявлен. Когда разбор впервые доходит до поля next, узел зовёт f и запоминает ответ, второй раз функцию не зовут. Глубину дерева описаний это не меняет: оно конечное, в нём есть петля. А глубина данных может быть любой: тест этапа разбирает цепочку из тысячи перегонов. Ту же отсрочку ты делал руками в своём Гексе в уроке 30: функция вместо значения.

Проверка, которой нужен мир

Теперь книга башен. Знать, есть ли башня, схема сама не может: список башен меняется, лежит в базе и спрашивается по сети. Это сервис, как провод и журнал из EF5:

export class Registry extends Context.Service<
  Registry,
  { readonly known: (code: TowerCode) => Effect.Effect<boolean> }
>()('magistral/Registry') {}

Проверки из урока 78 были чистыми функциями: получили значение, ответили. Здесь ответ это эффект. Для этого у Schema есть геттеры из модуля SchemaGetter, и среди них checkEffect: проверка, которая возвращает эффект. Отвечает она так же, как фильтр: true значит годится, строка значит беда с этим текстом.

export const KnownTower = TowerCode.pipe(
  Schema.decode({
    decode: SchemaGetter.checkEffect((code: TowerCode) =>
      Effect.map(
        Registry.use((registry) => registry.known(code)),
        (known) => known || `башни ${code} нет в книге`,
      ),
    ),
    encode: SchemaGetter.passthrough(),
  }),
);

Schema.decode добавляет к схеме шаг: на разборе спросить книгу, на записи пропустить как есть. Шаг стоит после TowerCode, поэтому до книги доходят только коды правильного вида. На входе 'башня' разбор упадёт с код башни вида T-число, и книгу зря не потревожат. В тесте этапа это проверяет книга-шпион.

Главное видно в типе:

Schema.decodeUnknownEffect(KnownTower);
// (input: unknown) => Effect.Effect<TowerCode, Schema.SchemaError, Registry>

Registry попал в R разбора. Сервис, который понадобился одной башне в глубине поручения, поднимается по всей схеме до самого верха, как требование эффекта поднимается по yield* из урока 37. Разобрать поручение без книги не выйдет, и это видно ещё до запуска:

error TS2379: Argument of type 'Effect<{ readonly id: number & Brand<"FrameId">; readonly to: string & Brand<"TowerCode">; readonly path: Hop; }, SchemaError, Registry>' is not assignable to parameter of type 'Effect<{ readonly id: number & Brand<"FrameId">; readonly to: string & Brand<"TowerCode">; readonly path: Hop; }, SchemaError, never>' with 'exactOptionalPropertyTypes: true'. Consider adding 'undefined' to the types of the target's properties.
  Type 'Registry' is not assignable to type 'never'.

Это знакомый по Сундуку отказ: runSync хочет пустой R, а там книга. Effect.provide(Registry.layer(['T-1', 'T-4'])), и разбор пошёл:

башни T-9 нет в книге
  at ["to"]

Беда пришла с путём, как любая другая: explain из прошлого урока превратит её в to: башни T-9 нет в книге без единой новой строки.

Что ещё можно делать в checkEffect: всё, что умеет эффект. Ждать ответа по сети, брать время из Clock, ставить timeout из EF8. Поэтому проверки с миром в Schema называют асинхронными. Синхронный разбор такую схему не примет: Schema.decodeUnknownSync(KnownTower) не собирается, потому что у него нет места для сервисов, его DecodingServices обязан быть never.

Где тут дыра

const route = (input: unknown) =>
  Effect.gen(function* () {
    const dispatch = yield* Schema.decodeUnknownEffect(Dispatch)(input);
    if (!(yield* Registry.use((registry) => registry.known(dispatch.to)))) {
      return yield* Effect.fail('нет башни');
    }
    return dispatch;
  });

В одном месте работает. Но поручения Академия принимает в трёх: ночью с провода, днём из конторы и из архива при перезапуске. Ринцвальд вставил if в первые два. Через месяц в третьем месте всплывает поручение в снесённую T-9, и тип Dispatch ничем не выдал, что оно не проверено. Хуже того, отказ 'нет башни' строкой не похож на остальные беды разбора: ни пути, ни места в explain.

Лечится тем, что проверка становится частью схемы. Тогда значение типа Dispatch нельзя получить, не спросив книгу, а забыть спросить нельзя: R не даст.

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

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

Практика

Дальше

Вечером к Казимиру выстраивается очередь из посыльных. Каждый держит пустой лист и печать своей гильдии.

Копии договора, которые выводятся из одной схемы, ждут в следующем уроке.