В уроке 05 ты видел, что Ping стоит на полке Message: каждый пинг это сообщение, поэтому пинг подходит везде, где ждут сообщение. Ринцвальд делает следующий шаг, и выглядит он безобидно: раз пинг это сообщение, то всё, что «про пинги», это и «про сообщения». Очередь пингов это очередь сообщений, отправитель пингов это отправитель сообщений.
Для очереди это правда. Для отправителя нет. Разница в том, куда движется сообщение: из коробки наружу или снаружи в коробку. Правило, по которому подстановка Ping вместо Message переносится (или не переносится) на тип вокруг, называется вариантностью.
В этом уроке ты разберёшь, почему получателя пингов можно отдать вместо общего, а отправителя нельзя, почему ошибки двух задач из урока 11 складываются в объединение, и почему never подходит куда угодно. В конце ты прочтёшь первую строку настоящего объявления Effect и поймёшь в ней каждое слово.
Коробка, которая отдаёт
Представь полку в Библиотеке, с которой книги можно только брать. Тебе нужна полка, с которой снимают «какую-нибудь книгу». Полка, где стоят одни атласы, подходит: что бы ты с неё ни снял, это книга. Ты просил книгу и получил книгу.
Сообщения, с которыми будем работать весь урок:
export type Text = { readonly _tag: 'Text'; readonly body: string };
export type Ping = { readonly _tag: 'Ping' };
export type Message = Text | Ping;
export const ping: Ping = { _tag: 'Ping' };
export const text = (body: string): Text => ({ _tag: 'Text', body });
Это файл messages.ts, остальные примеры его импортируют. Получатель умеет только одно: отдать следующее сообщение или undefined, если очередь пуста.
import { type Message, type Ping, ping } from './messages';
type Receiver<M> = { readonly receive: () => M | undefined };
const pings: Receiver<Ping> = { receive: () => ping };
const inbox: Receiver<Message> = pings;
const next = inbox.receive();
if (next !== undefined) console.log(next._tag);
Компилятор молчит. Тот, кто держит inbox, ждёт от receive значение Message | undefined. Получает пинг, а пинг это сообщение. Обещание Receiver<Message> выполнено, хотя внутри очередь только пингов.
Подстановка прошла в ту же сторону, что у параметра: Ping подходит вместо Message, и Receiver<Ping> подходит вместо Receiver<Message>. Такой тип ковариантен по M. Слово длинное, а правило короткое: кто только отдаёт, тот ковариантен. Тип результата функции, Promise<M>, readonly M[]: все они отдают M наружу, и Promise<Ping> подходит вместо Promise<Message> по той же причине.
Коробка, которая берёт
Теперь ящик для возврата книг. Тебе нужен ящик, куда можно бросить любую книгу. Ящик «только для атласов» не подходит: ты бросишь туда роман, и тот, кто разбирает ящик, полезет искать в нём карты.
С пингом эту беду не видно: у пинга нет полей, и ломать там нечего. Возьмём отправителя текстов, у которого есть что ломать. Он кричит текст в рупор башни заглавными буквами:
import { type Message, type Text, ping } from './messages';
type Sender<M> = { readonly send: (message: M) => void };
const shout: Sender<Text> = {
send: (message) => console.log(message.body.toUpperCase()),
};
const broadcast: Sender<Message> = shout;
broadcast.send(ping);
Если бы девятая строка прошла, десятая отдала бы пинг в функцию, которая лезет в message.body. У пинга body нет, и вызов toUpperCase у undefined уронил бы башню. Библиотекарь ловит это раньше.
Перевод с Ук
sender.ts(9,7): error TS2322: Type 'Sender<Text>' is not assignable to type 'Sender<Message>'.
Type 'Message' is not assignable to type 'Text'.
Property 'body' is missing in type 'Ping' but required in type 'Text'.
Читаем снизу вверх. Последняя строка: у Ping нет поля body, а Text его требует. Выше: Message не подходит вместо Text, потому что среди сообщений есть пинг. Верхняя строка: значит, Sender<Text> не подходит вместо Sender<Message>.
Теперь посмотри на направление. Ты присваивал Sender<Text> туда, где ждут Sender<Message>, то есть шёл от Text к Message. А вторая строка ошибки проверяет обратное: подходит ли Message вместо Text. Чтобы сравнить двух отправителей, компилятор сравнил их параметры задом наперёд. Поэтому Библиотекарь и стучал по второй строке: если в ошибке направление перевернулось, параметр стоит там, где тип что-то берёт.
Обратная подстановка безопасна:
import { type Message, type Text, text } from './messages';
type Sender<M> = { readonly send: (message: M) => void };
const journal: Sender<Message> = {
send: (message) => console.log(message._tag),
};
const texts: Sender<Text> = journal;
texts.send(text('гроза у моста'));
Журнал башни принимает любое сообщение, значит, примет и текст. Подстановка пошла против параметра: Text подходит вместо Message, а Sender<Message> подходит вместо Sender<Text>. Такой тип контравариантен. Кто только берёт, тот контравариантен.
Это ответ Ринцвальду. Его отправитель пингов обещает справиться только с пингами. В общий канал отправят текст, и обещание сломается. Наоборот можно: общий отправитель справится и с пингом.
Что тип делает с M | Куда идёт подстановка | Как называется |
|---|---|---|
| только отдаёт | в ту же сторону | ковариантен |
| только берёт | в обратную | контравариантен |
| и берёт, и отдаёт | никуда | инвариантен |
не трогает M вовсе | в обе | бивариантен |
Ошибки складываются
Вернись к своей ленивой задаче из урока 11:
type Outcome<A, E> =
{ readonly ok: true; readonly value: A } | { readonly ok: false; readonly error: E };
type Task<A, E> = () => Promise<Outcome<A, E>>;
declare const lostOnly: Task<number, 'Lost'>;
declare const safe: Task<number, never>;
const wider: Task<number, 'Lost' | 'Garbled'> = lostOnly;
const anywhere: Task<number, 'Lost'> = safe;
const narrower: Task<number, never> = lostOnly;
Где в Task стоит E? Внутри Outcome, внутри Promise, в результате функции. Задача ошибку только отдаёт, никто не передаёт ошибку в задачу. Значит, Task ковариантен по E, и первые две подстановки проходят.
wider: задача, которая может упасть только с'Lost', подходит туда, где готовы к'Lost'или'Garbled'. Тот, кто держитwider, разберёт оба случая, а придёт только один. Это пара из урока 05: больше значений, меньше гарантий. Про ошибкуwiderизвестно меньше, чем про ошибкуlostOnly, но ничего не сломалось.anywhere: задача с ошибкойneverподходит туда, где ждут ошибку'Lost'.neverэто пустая полка из урока 05, она вложена в любую другую. Задача, которая не падает никогда, выполняет любое обещание о том, как она упадёт.narrowerне проходит:
task.ts(10,7): error TS2322: Type 'Task<number, "Lost">' is not assignable to type 'Task<number, never>'.
Type '"Lost"' is not assignable to type 'never'.
Задача, которая может потеряться, не выдаст себя за ту, что не падает. В пустую полку не поставить ни одной книги.
Теперь понятно, почему andThen из урока 11 возвращает Task<B, E | E2>. Цепочка из двух задач может упасть ошибкой первой или ошибкой второй. Результату нужен тип ошибки, в который помещаются обе: тогда и E, и E2 по ковариантности встают на его место. Самая узкая такая полка это объединение E | E2. А succeed возвращает Task<A, never>, потому что задача без ошибки встаёт в любую цепочку и ничего к ошибкам не добавляет: E | never это E.
Вот первая строка объявления Effect из пакета effect@4.1.0:
export interface Effect<out A, out E = never, out R = never> extends Pipeable, Inspectable {
Три параметра, у каждого слово out, и у ошибки по умолчанию never. Это та же квитанция, что у твоего Task, только с третьим полем. A и E ведут себя так же, как в Task: эффект с меньшим набором ошибок подходит туда, где готовы к большему, и ошибки двух эффектов складываются объединением. Что такое R, разберём позже в курсе. Пока запомни, что и он out. Осталось выяснить, что это за out.
Аннотации in и out
Компилятор умеет сам вычислить, как дженерик ведёт себя по параметру. Но с TypeScript 4.7 это можно сказать явно: out у параметра, который тип только отдаёт, in у того, который только берёт, и in out у того, который тип и берёт, и отдаёт.
import type { Message, Ping } from './messages';
interface Sender<in M> {
readonly send: (message: M) => void;
}
interface Receiver<out M> {
readonly receive: () => M | undefined;
}
interface Slot<in out M> {
value: M;
}
declare const journal: Sender<Message>;
declare const pings: Receiver<Ping>;
declare const pingSlot: Slot<Ping>;
const forPings: Sender<Ping> = journal;
const inbox: Receiver<Message> = pings;
const slot: Slot<Message> = pingSlot;
Первые две подстановки проходят, последняя нет: в Slot<Message> можно записать текст, и он окажется в ячейке, где ждут только пинги. Аннотация даёт три вещи.
-
Компилятор проверяет тебя. Ошибся в аннотации, и он скажет. Пометь получателя как
in:import type { Message, Ping } from './messages'; interface Receiver<in M> { readonly receive: () => M | undefined; } declare const pings: Receiver<Ping>; const inbox: Receiver<Message> = pings;annot.ts(3,20): error TS2636: Type 'Receiver<super-M>' is not assignable to type 'Receiver<sub-M>' as implied by variance annotation. The types returned by 'receive()' are incompatible between these types. Type 'super-M | undefined' is not assignable to type 'sub-M | undefined'. Type 'super-M' is not assignable to type 'sub-M | undefined'.Ошибка стоит на самой аннотации, третья строка, двадцатый столбец:
inобещает, что тип только берётM, аreceiveего отдаёт. Загадочныеsuper-Mиsub-Mразберём в «Под капотом». -
Компилятор быстрее. Ему не нужно выяснять вариантность опытом, о котором ниже: направление ты указал сам. На больших рекурсивных типах, вроде тех, из которых собран Effect, это заметно.
-
Читатель видит договор. Ты открыл объявление
Effect, увиделout Eи знаешь, куда пойдёт подстановка, не разбирая тело интерфейса.
Аннотация может сделать тип строже, чем вышло бы из его устройства, но не свободнее: out у того, кто берёт, компилятор не пропустит. Строже и нужно у Slot: без in out компилятор пропустил бы Slot<Ping> вместо Slot<Message>, почему, увидишь ниже.
Попробуй сам. Ниже три коробки с аннотациями: «только отдаёт», «только берёт» и коробка, из которой можно и достать, и положить. Подставь Ping вместо Message и обратно и посмотри, где подстановка разрешена и что сломалось бы там, где нет.
Под капотом
Когда компилятору впервые нужно сравнить Receiver<X> с Receiver<Y>, он берёт два служебных типа-метки, sub-M и super-M, и договаривается, что первый подходит вместо второго. Потом подставляет их в Receiver и структурно проверяет обе стороны: проходит ли Receiver<sub-M> вместо Receiver<super-M> и наоборот. Прошло в ту же сторону, значит ковариантен; в обратную, контравариантен; в обе, бивариантен; ни в одну, инвариантен. Ответ запоминается на весь тип, и дальше Receiver<Ping> против Receiver<Message> сводится к сравнению Ping с Message. Аннотация проверяется тем же опытом, поэтому в ошибке TS2636 и видны метки.
У опыта есть две слабости. Первая: изменяемые поля. Поле value: M и читают, и пишут, честный ответ здесь «инвариантен». Но TypeScript сравнивает свойства объектов по типу чтения и пропускает { value: Ping } вместо { value: Message }. Отсюда in out у Slot.
Вторая слабость: методы. Запиши отправителя не свойством, а методом:
import { type Message, type Text, ping } from './messages';
type Sender<M> = { send(message: M): void };
const shout: Sender<Text> = {
send: (message) => console.log(message.body.toUpperCase()),
};
const broadcast: Sender<Message> = shout;
broadcast.send(ping);
Компилятор молчит, а в рантайме:
TypeError: Cannot read properties of undefined (reading 'toUpperCase')
Флаг strictFunctionTypes из strict проверяет параметры функций контравариантно, но только у свойств с функциональным типом. Параметры методов он сравнивает в обе стороны. Это сделано нарочно, в TypeScript 2.6: у Array<T> есть методы push и indexOf, которые берут T. Будь их параметры строгими, Ping[] перестал бы подходить вместо Message[], и сломался бы почти любой код с массивами. Поэтому в шифре отправитель записан свойством, readonly send: (message: M) => void, а не методом, и помечен in.
Где тут дыра
import { type Message, type Ping, ping, text } from './messages';
const pings: Ping[] = [ping, ping];
const count = (queue: Message[]): number => queue.length;
const urgent = (queue: Message[]): void => {
queue.unshift(text('гроза у моста'));
};
count(pings);
urgent(pings);
const first = pings[0];
if (first !== undefined) console.log(first._tag);
На count у Ринцвальда всё работает: функция только читает, а чтение ковариантно. Соседняя функция urgent вставляет в очередь срочный текст. Компилятор пропускает и её, а последняя строка печатает Text из массива, который по типу содержит одни пинги. Массив и берёт, и отдаёт, честно он инвариантен. TypeScript считает его ковариантным ради удобства, а unshift это метод, и его параметр проверяется в обе стороны.
Заплатка: там, где функция только читает, бери readonly Message[], а данные, которые должны остаться пингами, держи как readonly Ping[]:
import { type Message, type Ping, ping, text } from './messages';
const pings: readonly Ping[] = [ping, ping];
const count = (queue: readonly Message[]): number => queue.length;
const urgent = (queue: Message[]): void => {
queue.unshift(text('гроза у моста'));
};
count(pings);
urgent(pings);
arr-fix.ts(11,8): error TS2345: Argument of type 'readonly Ping[]' is not assignable to parameter of type 'Message[]'.
The type 'readonly Ping[]' is 'readonly' and cannot be assigned to the mutable type 'Message[]'.
У readonly массива нет unshift, он только отдаёт, и ковариантность у него честная. count принимает его без вопросов, а urgent получает «Ук».
Шифр, этап 8
Башня T-1 получает кадры из одной очереди и раздаёт их дальше: журналу, рупору, соседней башне. В этом этапе протокол учится перекладывать сообщения из получателя в отправителя. Типы следят, чтобы общий журнал подходил везде, очередь пингов отдавалась туда, где ждут любые сообщения, а текст никогда не попал в отправителя, который умеет только пинги.
Практика
Глубже в архиве
Что значит «система типов честна» строго, разбирает урок «Безопасность: продвижение и сохранение типа» из курса логики. Массив Ринцвальда нарушает ровно второе обещание: значение в рантайме перестало быть того типа, который у него в коде. А как устроен тип функции, у которой вход и выход, показывает урок «Простое типизированное λ-исчисление».
Дальше
Ночью башня T-1 стоит закрытой: ставни на засове, в журнале ни одного кадра. А утром на проводе Магистрали лежит кадр с подписью T-1, и соседняя башня уже ответила на него подтверждением. Кто-то говорит голосом Академии, и твой протокол ему не мешает: 'T-1' может написать от руки кто угодно. В следующем уроке делаем номера, которые нельзя подделать.