Свитку из прошлого урока нужен провод. Взять его можно двумя привычными способами, и оба плохи.
Первый: импортировать провод модулем. Свиток находит его сам, а проверка не может его подменить, и ночная сводка в тесте пишет в настоящую Магистраль. Это скрытый вход из урока 17, только спрятанный не в часах, а в import.
Второй: передать провод аргументом. Вход виден, подменить легко. Но свиток сводки зовёт свиток чтения, тот зовёт свиток разбора, а провод нужен только последнему. Значит, провод потащат через все три сигнатуры, а заодно часы, журнал и ставни. Каждый новый сервис в глубине переписывает всех, кто стоит выше.
Нужен третий способ: свиток просит сервис по имени, а получает его при запуске. Кто что просит, видно в типе, а запустить свиток, которому чего-то не хватает, не даёт компилятор.
Ярлык сервиса
Сервис это объект с делом. Провод умеет читать и отправлять, часы умеют сказать время:
export type WireError = { readonly _tag: 'WireDown'; readonly tower: string };
export type WireService = {
readonly read: IO.IO<readonly string[], WireError>;
readonly send: (frame: string) => IO.IO<void, WireError>;
};
export type ClockService = { readonly now: IO.IO<number> };
Заметь, что дела сервисов это свитки: send ничего не отправляет, а записывает отправку. Так сервис встраивается в свитки, которые его используют.
Чтобы попросить сервис, нужно его имя и форма. Их вместе держит ярлык:
export type Tag<Id extends string, S> = { readonly key: Id; readonly '~service'?: S };
export const tag =
<S>() =>
<const Id extends string>(key: Id): Tag<Id, S> => ({ key });
export const Wire = tag<WireService>()('Wire');
export const Clock = tag<ClockService>()('Clock');
У значения ярлыка одно поле: Wire это { key: 'Wire' }. Поля ~service у значения нет, оно существует только в типе и несёт форму сервиса, как бренд из урока 14. Вызова два, потому что TypeScript не умеет вывести один параметр типа, если другой задан руками: форму S пишут явно, а имя Id выводится литералом 'Wire' благодаря const.
Свиток, который просит
Свиток получает третью букву: IO<A, E, R> даст A, может упасть с E и просит сервисы с именами R. Где он их возьмёт? Пусть run получает их при запуске одним объектом:
/** Сундук, в котором точно есть сервисы с именами `R`. */
export type Context<R extends string> = Readonly<Record<R, unknown>>;
export type IO<A, E = never, R extends string = never> = {
readonly run: (context: Context<R>) => Exit.Exit<A, E>;
};
Этот объект называется контекстом. Свиток с R = 'Wire' | 'Clock' запускают только с контекстом, где лежат и провод, и часы.
Просят сервис функцией service, кладут его в Сундук функцией provideService:
export const service: <Id extends string, S>(tag: Tag<Id, S>) => IO<S, never, Id>;
export const provideService: <A, E, R extends string, Id extends string, S>(
self: IO<A, E, R>,
tag: Tag<Id, S>,
implementation: S,
) => IO<A, E, Exclude<R, Id>>;
service(Wire) это свиток, который отдаёт провод из Сундука и поэтому просит 'Wire'. Когда такой свиток продолжают через flatMap свитком, который просит 'Clock', нужды складываются в объединение, как ошибки: свиток сводки просит 'Wire' | 'Clock'.
export const checkTower: IO.IO<string, WireError, 'Wire' | 'Clock'> = IO.flatMap(
IO.service(Wire),
(wire) =>
IO.flatMap(wire.read, (frames) =>
IO.flatMap(IO.service(Clock), (clock) =>
IO.map(clock.now, (now) => `${now}: кадров ${frames.length}`),
),
),
);
provideService вычитает имя из нужд. Тип Exclude<R, Id> из урока 11 распределяется по объединению: Exclude<'Wire' | 'Clock', 'Wire'> даёт 'Clock'. Когда вычли всё, остаётся never, и только такой свиток принимает runSync:
import * as IO from './IO';
import { checkTower, Clock, Wire } from './tower';
const wire = {
read: IO.succeed(['T-3->T-1|1|Ping|', 'T-3->T-1|2|Text|гроза']),
send: () => IO.succeed(undefined),
};
const clock = { now: IO.succeed(1030) };
const ready = IO.provideService(IO.provideService(checkTower, Wire, wire), Clock, clock);
console.log(IO.runSync(ready));
1030: кадров 2
Провод здесь ненастоящий, часы стоят на 1030, и ночную сводку проверили днём. Ни один свиток при этом не переписан: подменили содержимое Сундука.
Перевод с Ук
Провод положили, про часы забыли:
import * as IO from './IO';
import { checkTower, Wire } from './tower';
const wire = {
read: IO.succeed(['T-3->T-1|1|Ping|']),
send: () => IO.succeed(undefined),
};
const half = IO.provideService(checkTower, Wire, wire);
IO.runSync(half);
ook.ts(10,12): error TS2345: Argument of type 'IO<string, WireError, "Clock">' is not assignable to parameter of type 'IO<string, WireError, never>'.
Types of property 'run' are incompatible.
Type '(context: Readonly<Record<"Clock", unknown>>) => Exit<string, WireError>' is not assignable to type '(context: Readonly<Record<never, unknown>>) => Exit<string, WireError>'.
Types of parameters 'context' and 'context' are incompatible.
Property 'Clock' is missing in type 'Readonly<Record<never, unknown>>' but required in type 'Readonly<Record<"Clock", unknown>>'.
Пять строк, и читать их снизу. Последняя: в пустом Сундуке, Record<never, unknown>, нет поля Clock, а свитку оно обязательно. Строкой выше: поэтому не сходятся параметры context, тот, с которым запустят, и тот, который нужен свитку. Ещё выше: поэтому не сходятся функции run. Первая строка подводит итог: свиток, которому всё ещё нужны часы, "Clock", не помещается туда, где ждут свиток без нужд, never. Компилятор назвал недостающий сервис по имени. Чинится третьим provideService с часами.
Ближний главнее
Сундук у каждого свитка свой. provideService кладёт сервис только для того свитка, который ему дали, и если тот же ярлык положили и внутри, и снаружи, свиток видит ближний:
import * as IO from './IO';
import { Clock, type ClockService } from './tower';
const clock = (now: number): ClockService => ({ now: IO.succeed(now) });
const now = IO.flatMap(IO.service(Clock), (c) => c.now);
const inner = IO.provideService(now, Clock, clock(1));
const both = IO.flatMap(inner, (first) => IO.map(now, (second) => [first, second]));
console.log(IO.runSync(IO.provideService(both, Clock, clock(2))));
[ 1, 2 ]
Первый now запущен с часами 1, положенными прямо к нему. Второй now стоит снаружи и видит только внешние часы 2. Так в одной программе можно дать одному свитку тестовые часы, не трогая остальных.
Под капотом
R стоит в типе параметра run. В уроке 13 было правило: кто принимает, тот контравариантен. Но Context<R> сам растёт в обратную сторону: Сундук с проводом и часами подходит туда, где ждут Сундук с одним проводом, лишние сервисы не мешают. Два переворота дают прямое направление: IO ковариантен по R. Свиток, который ничего не просит, IO<A, E, never>, подходит везде, где ждут свиток с нуждами: ему не понадобится то, что лежит в Сундуке. А свиток с нуждами туда, где ждут свиток без нужд, не подходит, и это ровно та ошибка, которую мы только что переводили.
Отсюда и объединение. Свиток из двух шагов запустят с одним Сундуком, и в нём должно лежать всё, что нужно и первому шагу, и второму. Имена складываются, как буквы в списке покупок.
Внутри provideService есть одно место, где компилятору приходится поверить на слово:
run: (context) => self.run({ ...context, [tag.key]: implementation } as Context<R>),
Свитку self нужен Сундук с именами R, а снаружи пришёл Сундук с Exclude<R, Id>. Мы добавили ровно один сервис, Id, и вместе получилось R. Компилятор не умеет сложить это сам, поэтому стоит as. Второй такой as живёт в service: значение из Сундука лежит как unknown, и его приводят к форме из ярлыка. Оба честные по одной причине: что положили под именем, то и достанут, а положить под именем можно только то, что подходит ярлыку.
Где тут дыра
// magistral.ts
export const wire = {
send: (frame: string): void => {
console.log(`настоящий провод: ${frame}`);
},
};
// relay.ts
import * as IO from './IO';
import { wire } from './magistral';
export const relay = (frame: string): IO.IO<void> => IO.sync(() => wire.send(frame));
// check.ts
import * as IO from './IO';
import { relay } from './relay';
// Проверка: хотим убедиться, что пинг уходит, и не трогать Магистраль.
IO.runSync(relay('T-1->T-7|1|Ping|'));
настоящий провод: T-1->T-7|1|Ping|
Проверка отправила пинг в настоящую Магистраль. Подменить провод нечем: relay нашёл его сам, через import, и в типе IO<void> об этом ни слова. В бою это работает, а в проверке каждый прогон шлёт кадры живым башням. Ринцвальду придётся либо не проверять отправку вовсе, либо подменять модуль целиком средствами тестового фреймворка, то есть тем самым моком, от которого Матушка отучала в уроке 18. С Сундуком проверка кладёт в него свой провод.
Вот куда придёшь
В Effect ярлык сервиса это класс, который наследует Context.Service, а нужды стоят третьей буквой Effect<A, E, R>:
import { Context, Effect } from 'effect';
class Clock extends Context.Service<Clock, { readonly now: Effect.Effect<number> }>()('Clock') {}
const stamp = Effect.flatMap(Effect.service(Clock), (clock) => clock.now);
console.log(Effect.runSync(Effect.provideService(stamp, Clock, { now: Effect.succeed(1030) })));
Имя 'Clock' то же, вызова тоже два, по той же причине. А в R у Effect лежат не строки, а сами классы: два сервиса с одним именем из разных модулей там не спутать. Подробнее о сервисах и слоях, которые их собирают, расскажет Рыдкулл в разделе EF5 «Сундук».
Гекс, этап 3
Гекс получает Сундук. Ты заведёшь модуль src/Context.ts с ярлыками, добавишь в src/IO.ts третью букву, service и provideService, и напишешь в src/tower.ts сервисы башни и свиток сводки. Начинка IO.ts снова меняется: run получает Сундук. Тесты прошлых этапов остаются.
Практика
Глубже в архиве
То, что делает Сундук, в Haskell называют монадой Reader: вычисление, которое читает общее окружение и не тащит его через аргументы. Как она устроена и чем отличается от Writer и State, разбирает урок «Reader, Writer и State», а как разные языки и библиотеки решают задачу эффектов и окружения, сравнивает урок «Ландшафт эффект-систем».
Дальше
Сундук закрывает крышку, свиток сводки исполнен с ненастоящими часами. На лестнице шаги: Ринцвальд тащит рулон, который не пролезает в дверь.
Ринцвальд запускает, и в подвале что-то трещит. Почему Гекс на замыканиях не выдерживает длинного свитка и как перестроить его так, чтобы длина не имела значения, разберём в следующем уроке.