В уроке 03 ты превратил промис в рецепт: () => Promise<A> ничего не делает, пока его не вызвали. В уроке 09 ты прочитал карточку Effect<A, E, R> и увидел, чего рецепту не хватает: ячейки для ошибки. Сегодня ты её добавишь. R пока оставим: это то, что задаче понадобится, и до этой ячейки курс дойдёт позже.
На вопрос Пондера отвечают два инструмента. Первый это объединение: ошибки двух задач складываются. Второй это условные типы с infer: ими из готового типа достают его части. С ними легко увлечься головоломками на сотню строк. Курс учит другому: прочитать ReturnType, Awaited и Effect.Success и написать два своих помощника в одну строку каждый.
Своя задача
export type Outcome<A, E> =
{ readonly ok: true; readonly value: A } | { readonly ok: false; readonly error: E };
export type Task<A, E> = () => Promise<Outcome<A, E>>;
Outcome это квитанция: либо значение, либо ошибка, и поле ok говорит, что из двух. Это размеченное объединение из урока 07, только метка булева. Task это функция, которая вернёт квитанцию, когда её вызовут. Промис внутри есть, но создаётся он при вызове, а не при описании.
Две самые простые задачи:
export const succeed =
<A>(value: A): Task<A, never> =>
async () => ({ ok: true, value });
export const fail =
<E>(error: E): Task<never, E> =>
async () => ({ ok: false, error });
never здесь значит то же, что пустая полка из урока 05. Task<A, never> значит «падать нечем»: значений типа ошибки нет, значит, и ошибки не будет. Task<never, E> значит «значения не будет, будет только ошибка».
Ошибки складываются
Задачи нужно связывать: прочитать кадр, потом доставить. Вторая задача зависит от результата первой.
export const andThen =
<A, E, B, E2>(task: Task<A, E>, next: (value: A) => Task<B, E2>): Task<B, E | E2> =>
async () => {
const outcome = await task();
return outcome.ok ? next(outcome.value)() : outcome;
};
Построчно. andThen берёт задачу и функцию, которая по её значению строит следующую. Отдаёт новую задачу, и пока её не вызвали, ни task, ни next не работают. При вызове первая задача выполняется. Если квитанция с ошибкой, она возвращается как есть: её тип { readonly ok: false; readonly error: E }, а он подходит к любому Outcome<B, E | E2>, потому что значения в нём нет. Если с успехом, next строит вторую задачу, и она выполняется.
Самое важное стоит в типе результата: E | E2. Цепочка может упасть на первом шаге или на втором, поэтому её ошибка это объединение обеих.
const readFrame = (raw: string): Task<string, 'Garbled'> =>
raw.length > 0 ? succeed(raw) : fail('Garbled');
const deliver = (frame: string): Task<number, 'Lost'> =>
frame === 'T-0' ? fail('Lost') : succeed(frame.length);
const chain = andThen(readFrame('T-1'), deliver);
// Task<number, 'Garbled' | 'Lost'>
const safe = andThen(succeed(1), (n) => succeed(n + 1));
// Task<number, never>
У safe ошибка never | never, то есть never: объединение с пустой полкой ничего не добавляет. Задачи, которые не падают, складываются в задачу, которая не падает. Это первый ответ Пондеру.
Сравни с карточкой Effect.flatMap из effect 4.1, вот его форма с данными первым аргументом:
<A, E, R, B, E1, R1>(self: Effect<A, E, R>, f: (a: A) => Effect<B, E1, R1>): Effect<B, E | E1, R | R1>;
Та же сигнатура, что у твоего andThen, только ячеек три и складываются две: ошибки и то, что нужно задаче.
Перевод с Ук
В первой версии andThen легко забыть вторую ошибку в типе результата:
export const andThen =
<A, E, B, E2>(task: Task<A, E>, next: (value: A) => Task<B, E2>): Task<B, E> =>
async () => {
const outcome = await task();
return outcome.ok ? next(outcome.value)() : outcome;
};
task.ts(7,3): error TS2322: Type '() => Promise<{ readonly ok: false; readonly error: E; } | Outcome<B, E2>>' is not assignable to type 'Task<B, E>'.
Type 'Promise<{ readonly ok: false; readonly error: E; } | Outcome<B, E2>>' is not assignable to type 'Promise<Outcome<B, E>>'.
Type '{ readonly ok: false; readonly error: E; } | Outcome<B, E2>' is not assignable to type 'Outcome<B, E>'.
Type '{ readonly ok: false; readonly error: E2; }' is not assignable to type 'Outcome<B, E>'.
Type '{ readonly ok: false; readonly error: E2; }' is not assignable to type '{ readonly ok: false; readonly error: E; }'.
Types of property 'error' are incompatible.
Type 'E2' is not assignable to type 'E'.
'E' could be instantiated with an arbitrary type which could be unrelated to 'E2'.
Восемь строк, и каждая следующая сдвинута вправо: это одна причина, которую TypeScript разворачивает слой за слоем. Читай снизу.
'E' could be instantiated with an arbitrary type which could be unrelated to 'E2':EиE2это два независимых параметра. Вызывающий код может подставить в них что угодно, например'Garbled'и'Lost', и ничто не обещает, что одно влезет в другое.Type 'E2' is not assignable to type 'E': суть ошибки. Тип обещает ошибкуE, а может прийтиE2.Types of property 'error' are incompatible: где, в полеerror.- Две строки выше называют, чья это квитанция: провальная квитанция второй задачи,
{ ok: false; error: E2 }, не влезла вOutcome<B, E>. - Остальные строки это обёртки: квитанция внутри промиса, промис внутри функции. Верхняя говорит только, что вся функция не подходит к
Task<B, E>.
В длинной лесенке верхняя строка показывает, где сломалось, а нижняя показывает, почему. Середину можно читать, только если низ непонятен. Здесь низ понятен: вторая ошибка пропала из типа, и компилятор не даёт её спрятать. Добавь её в объединение, Task<B, E | E2>, и лесенка исчезнет.
Условный тип: тернарный оператор для типов
Библиотекарь разбирает тележку с возвращёнными книгами. Про каждую он задаёт один вопрос: «стоит ли она на полке сообщений с текстом?». Если да, книга едет направо, если нет, налево. Это условный тип:
type IsText<M> = M extends { readonly _tag: 'Text' } ? true : false;
type A = IsText<Text>;
// true
type B = IsText<Ping>;
// false
extends здесь читай так же, как присваиваемость в уроке 05: «входит ли множество M в множество справа». После ? тип для «да», после : тип для «нет». Как у тернарного оператора в выражениях, только вычисляет компилятор, а не рантайм.
infer: достать часть типа
Сам по себе условный тип умеет только сортировать. Полезным его делает infer: переменная внутри образца. «Если тип подходит к образцу, назови то, что стоит на этом месте, и отдай мне».
type SuccessOf<T> = T extends Task<infer A, unknown> ? A : never;
type Delivered = SuccessOf<typeof chain>;
// number
Вслух: «если T это задача с каким-то значением A и любой ошибкой, отдай A, иначе never». Это похоже на деструктуризацию const [first] = route, только разбирается тип, а не массив.
Каждое слово образца здесь на своём месте.
unknownна месте ошибки значит «любая ошибка».unknownэто вся Библиотека, в неё входит любой тип, поэтому подойдёт задача с ошибкой'Lost','Garbled'иnever.neverв ветке «нет» значит «не задача, значит, и значения нет».SuccessOf<string>этоnever, пустая полка, а не ошибка компиляции.
Теперь можно читать то, что написано до тебя. Вот ReturnType из lib.es5.d.ts:
type ReturnType<T extends (...args: any) => any> = T extends (...args: any) => infer R ? R : any;
Тот же узор: если T это функция, назови то, что она возвращает. ReturnType<typeof deliver> даёт Task<number, 'Lost'>.
Awaited сложнее, это тип, которым TypeScript описывает await. Здесь он без комментариев из lib.es5.d.ts:
type Awaited<T> = T extends null | undefined ? T :
T extends object & { then(onfulfilled: infer F, ...args: infer _): any; } ?
F extends ((value: infer V, ...args: infer _) => any) ?
Awaited<V> :
never :
T;
Три вложенных вопроса. Это null или undefined? Тогда отдай как есть. Это объект с методом then? Тогда назови F первый параметр then. Это функция? Тогда назови V то, что она получает, и повтори всё для V. На каждом «нет» тип отдаётся как есть. Поэтому Awaited<Promise<Promise<number>>> даёт number, а Awaited<number> тоже number. А Awaited<ReturnType<Task<number, 'Lost'>>> это Outcome<number, 'Lost'>: квитанция, которую ты получишь после await task().
И последняя карточка, тоже из node_modules/effect/dist/Effect.d.ts:
export type Success<T> = T extends Effect<infer _A, infer _E, infer _R> ? _A : never;
Это твой SuccessOf, только для трёх ячеек. Рядом лежат Effect.Error и Effect.Services, устроенные так же. Ещё неделю назад эта строка была для тебя шифром.
Распределение по объединению
Задачи бывают разными, и их часто держат в одном объединении. Что даст ErrorOf для объединения двух задач?
type ErrorOf<T> = T extends Task<unknown, infer E> ? E : never;
type Errors = ErrorOf<Task<1, 'a'> | Task<2, 'b'>>;
// 'a' | 'b'
Библиотекарь не сортирует тележку целиком, он берёт книги по одной. Условный тип делает так же: применяется к каждому члену объединения отдельно, а ответы складывает. Это называется распределением. Поэтому IsText<Text | Ping> это не false, а true | false, то есть boolean.
На распределении держатся Exclude и Extract из стандартной библиотеки. Вот Exclude:
type Exclude<T, U> = T extends U ? never : T;
type Left = Exclude<'Garbled' | 'Lost', 'Lost'>;
// 'Garbled'
Каждый член проверяется отдельно: 'Garbled' не входит в 'Lost' и остаётся, 'Lost' входит и превращается в never, а never из объединения исчезает. Так из ошибки задачи вычитают то, что уже обработано. В EF4 настоящий Effect будет делать это за тебя.
Иногда распределение мешает. Вспомни каналы из урока 08: ты хочешь один канал для текстов и пингов вместе, а не два отдельных.
type Inbox<M> = M extends unknown ? Channel<M> : never;
type Split = Inbox<Text | Ping>;
// Channel<Text> | Channel<Ping>
type Shared<M> = [M] extends [unknown] ? Channel<M> : never;
type Joint = Shared<Text | Ping>;
// Channel<Text | Ping>
Квадратные скобки вокруг M выключают распределение: слева уже не голый параметр, а кортеж, и объединение проверяется целиком.
Под капотом
Из этого правила есть следствие, о которое часто спотыкаются. never это пустое объединение, ноль членов. Распределять нечего, считать нечего, и ответ тоже пустой:
type C = IsText<never>;
// never, а не false
Ни одна ветка не выбрана, потому что ни одной проверки не было. В большинстве типов это то, что нужно: SuccessOf<never> честно даёт never. Но если ты проверяешь, не пустой ли тип, голый параметр соврёт. Проверяющих проверять будешь в уроке 15, и там это пригодится.
Второе следствие: распределение не видно снаружи. ErrorOf<X> выглядит одинаково, распределяется он или нет. Чтобы понять, что выйдет на объединении, нужно открыть объявление и посмотреть, голый ли параметр слева. Это и есть чтение сигнатуры.
Где тут дыра
Ринцвальд видел SuccessOf краем глаза и написал свой. Ошибку в образце он поставил never: «задача же успешная, ошибки в ней нет».
type Naive<T> = T extends Task<infer A, never> ? A : never;
type Counted = Naive<Task<number, never>>;
// number
На его примере работает: задача succeed не падает, образец подошёл. А на соседней задаче, которая может упасть:
type Delivered = Naive<ReturnType<typeof deliver>>;
const count: Delivered = 3;
naive.ts(24,7): error TS2322: Type '3' is not assignable to type 'never'.
Task<number, 'Lost'> не входит в Task<..., never>: образец с never значит «задача, которая не падает никогда», а эта падает. Ответ ушёл в ветку «нет», и Delivered стал never. Хуже всего, что ошибка встала не на Naive, а на строке, которая его использует, далеко от причины.
Правило для образцов: в позицию, которая тебе не важна, ставь самый широкий тип, а не самый узкий. Для ошибки в результате задачи самый широкий это unknown. Для параметров функции правило переворачивается: образец (...args: unknown[]) => infer R не примет функцию от строки, а (...args: never) => infer R примет. Стандартная библиотека обходит вопрос через any, поэтому в ReturnType стоит (...args: any). Почему у параметров всё наоборот, разберём в уроке 13.
Шифр, этап 6
Протокол башни получает ленивые задачи с ошибкой в типе. Связка andThen складывает ошибки двух шагов, а SuccessOf и ErrorOf читают из готовой задачи, что она отдаёт и чем падает. Это та же квитанция, что у Effect, только самодельная: когда в EF4 появится настоящий Effect.flatMap, ты узнаешь его по сигнатуре.
Практика
Глубже в архиве
Если хочется посмотреть, куда ведут условные типы дальше, рекурсивные типы и вычисления на уровне типов разобраны в уроках «Программирование на типах · фундамент» и «Программирование на типах · глубже». Там головоломки уместны; здесь хватит того, что ты умеешь их читать. Как распределение чинит кортеж [x, x], собранный из объединения, и при чём тут функции над типами, показывает раздел «HKT в прикладном коде».
Дальше
Поздно вечером на провод башни T-1 приходит кадр с Магистрали. Протокол его разбирает, квитанция зелёная. Отправитель в кадре: башня T-12a. Рыдкулл приносит карту Магистрали и молча раскладывает её на столе Библиотеки. Двенадцатая башня на карте есть. Двенадцатой «а» нет. Почему протокол это съел и как описать код башни типом, в следующем уроке.