Рыдкулл просит две вещи, которые тянут в разные стороны. Описать канал один раз значит не привязывать его к виду сообщения. Знать, что в нём лежит, значит привязать. В прошлом уроке ты описал четыре вида сообщений, теперь нужен канал, который подходит к любому из них, но каждый раз к одному.
С функциями ты решаешь такую задачу каждый день: тело одно, а значения разные, их передают параметром. Дженерик делает то же самое с типами. Это функция, которая принимает тип и возвращает тип.
Канал на всё
import { type Message, ping, text } from './shifr';
type AnyChannel = { readonly name: string; readonly sent: readonly Message[] };
const texts: AnyChannel = { name: 'тексты', sent: [text('гроза'), ping] };
Собирается: пинг стоит на полке Message, значит, помещается и в канал текстов. Канал на всё описан один раз, но про содержимое знает только «какое-то сообщение». Кто читает канал текстов, получает Message и снова разбирает теги, хотя и так знал, что там тексты. Четыре канала Ринцвальда своё содержимое знают, но их четыре.
Функция над типами
import { ping, type Text, text } from './shifr';
type Channel<M> = { readonly name: string; readonly sent: readonly M[] };
const texts: Channel<Text> = { name: 'тексты', sent: [text('гроза'), ping] };
channel.ts(5,70): error TS2741: Property 'body' is missing in type 'Ping' but required in type 'Text'.
Channel<M> читается как функция. M это параметр типа: место, куда при использовании подставят настоящий тип. Channel<Text> это вызов с аргументом Text, а результат вызова { readonly name: string; readonly sent: readonly Text[] }. Channel<Ping> даёт канал пингов. Описание одно, каналов сколько угодно, и каждый знает своё содержимое. Пинг в канал текстов больше не кладётся: компилятор сверил его с полкой Text и не нашёл body.
Тип с параметром называют дженериком. Чужими ты уже пользовался: Array<string>, Promise<number> и Record<Glyph, number> из урока 05 это вызовы функций над типами. Record принимает два аргумента: множество ключей и тип значений.
Функции с параметром типа
Параметр типа бывает и у обычной функции. Вот переименование канала:
import { type Channel, channel, type Ping } from './shifr';
const rename = <M>(target: Channel<M>, name: string): Channel<M> => ({ ...target, name });
const pings = channel<Ping>('пинги');
const moved = rename(pings, 'пинги у моста');
<M> перед скобками объявляет параметр типа, и дальше M можно писать в типах аргументов и результата. Связь, которую он даёт, и есть смысл дженерика: что пришло каналом пингов, то и ушло каналом пингов. При вызове rename(pings, ...) никто не пишет rename<Ping>: компилятор видит, что первый аргумент типа Channel<Ping>, сопоставляет его с Channel<M> и получает M = Ping. Это вывод типов из аргументов. Наведи курсор на moved, и увидишь Channel<Ping>.
Почему не написать (target: Channel<Message>, name: string): Channel<Message>? Собралось бы, но канал пингов на выходе стал бы каналом всего подряд. Без параметра функция принимает широкую полку и возвращает широкую полку. С параметром она возвращает ту же полку, что получила.
Перевод с Ук
Отправку в канал ты напишешь на этапе, а пока посмотри на вызов, которого боялся Рыдкулл:
import { channel, type Ping, ping, send, text } from './shifr';
const pings = channel<Ping>('пинги');
const ok = send(pings, ping);
const bad = send(pings, text('гроза'));
send.ts(5,25): error TS2345: Argument of type 'Text' is not assignable to parameter of type 'Ping'.
Types of property '_tag' are incompatible.
Type '"Text"' is not assignable to type '"Ping"'.
Три строки с отступами, и каждый отступ значит «потому что». Нижняя строка самая конкретная: литерал "Text" не помещается в литерал "Ping". Средняя говорит, где это нашлось: в поле _tag. Верхняя делает вывод: аргумент типа Text не помещается в параметр типа Ping. Откуда в параметре Ping, если send объявлен для любого M? Из первого аргумента: компилятор вывел M = Ping из pings, подставил его в message: M, и второй аргумент сверялся уже с пингом. Колонка 25 показывает на text('гроза'), а не на send: функция ни при чём, не подошёл аргумент.
Когда выводить не из чего
У channel параметр типа есть только в результате: <M>(name: string) => Channel<M>. Из строки 'мост' тип сообщений не вывести, и компилятор подставляет самое широкое, что может:
import { type Channel, channel, type Ping, ping, send, text } from './shifr';
const bridge = channel('мост');
const mixed = send(send(bridge, ping), text('гроза'));
const pings: Channel<Ping> = mixed;
unknown.ts(5,7): error TS2322: Type 'Channel<unknown>' is not assignable to type 'Channel<Ping>'.
Type 'unknown' is not assignable to type 'Ping'.
Канал вышел Channel<unknown>. На полку «вся Библиотека» помещается всё, и пинг с текстом легли рядом без единого «Ук». Ошибка случилась только в последней строке, когда канал попробовали выдать за канал пингов. Поэтому у таких функций аргумент типа пишут руками: channel<Ping>('пинги'), channel<Message>('мост').
Ограничения
Не всякий тип годится в параметр. Функция, которая выбирает из двух каналов более загруженный, должна читать sent, а про произвольный C компилятор ничего не знает:
const busier = <C>(a: C, b: C): C => (a.sent.length >= b.sent.length ? a : b);
busier-bad.ts(1,41): error TS2339: Property 'sent' does not exist on type 'C'.
busier-bad.ts(1,58): error TS2339: Property 'sent' does not exist on type 'C'.
C без ограничений может оказаться числом, строкой, чем угодно. Ограничение extends сужает, какие типы можно подставить:
import { channel, type Ping, ping, send } from './shifr';
const busier = <C extends { readonly sent: readonly unknown[] }>(a: C, b: C): C =>
a.sent.length >= b.sent.length ? a : b;
const quiet = channel<Ping>('тихий');
const loud = send(quiet, ping);
const winner = busier(quiet, loud);
busier(1, 2);
busier.ts(9,8): error TS2345: Argument of type 'number' is not assignable to parameter of type '{ readonly sent: readonly unknown[]; }'.
extends здесь значит то же, что вложенность из урока 05: C должен лежать внутри полки «объекты с массивом sent». Внутри функции у C есть sent, а снаружи в неё не передать число. При этом winner остаётся Channel<Ping>, а не «каким-то объектом с sent»: ограничение говорит, что умеет C, но не стирает, чем он был.
const: маршрут, который помнит имена
Кадр идёт по маршруту из башен, и протоколу хочется знать маршрут в типе: какие башни и в каком порядке. Обычный вывод имена теряет:
const plain = <T extends readonly string[]>(towers: T): T => towers;
const kept = <const T extends readonly string[]>(towers: T): T => towers;
const a = plain(['T-1', 'T-2']); // string[]
const b = kept(['T-1', 'T-2']); // readonly ["T-1", "T-2"]
Литерал массива компилятор по умолчанию расширяет: массив можно изменить, поэтому ['T-1', 'T-2'] для него это «какие-то строки». Модификатор const перед параметром типа, появившийся в TypeScript 5.0, просит выводить так, будто аргумент записан с as const: строки остаются литералами, массив становится кортежем только для чтения. Тип readonly ["T-1", "T-2"] помнит две башни, их имена и порядок. К третьей обратиться не выйдет:
import { route } from './shifr';
const path = route('T-1', 'T-2');
const first: 'T-1' = path[0];
const third = path[2];
tuple.ts(5,20): error TS2493: Tuple type 'readonly ["T-1", "T-2"]' of length '2' has no element at index '2'.
На этапе маршрут принимает башни списком аргументов, route('T-1', 'T-2'), и сигнатура у него <const T extends readonly string[]>(...towers: T) => T. С остаточным параметром компилятор и без const выводит кортеж ["T-1", "T-2"], но изменяемый, а тесты ждут readonly, так что const в сигнатуре нужен. В уроке про шаблонные литеральные типы коды башен в маршрутах научатся разбирать по буквам, и всё это держится на том, что имена не потерялись здесь.
Под капотом
Компилятор идёт по аргументам и собирает кандидатов для каждого параметра типа. В send(channel<Message>('мост'), ping) первый аргумент даёт кандидата Message, второй Ping. Дальше он ищет среди кандидатов тот, в который помещаются остальные: Ping лежит внутри Message, значит, M = Message, и пинг законно уходит в общий канал. В send(pings, text('гроза')) кандидаты Ping и Text друг в друга не помещаются. Тогда компилятор берёт первого, Ping, и сверяет с ним остальные аргументы, отсюда ошибка про второй. Объединение Ping | Text он сам не придумывает: так канал пингов втихую расширился бы, и вышло бы ровно то, чего боялся Рыдкулл.
Где тут дыра
Без восклицательного знака его строка выглядит так:
const last = <T>(items: readonly T[]): T => items[items.length - 1];
last-bad.ts(1,45): error TS2322: Type 'T | undefined' is not assignable to type 'T'.
'T' could be instantiated with an arbitrary type which could be unrelated to 'T | undefined'.
Нижняя строка пугает словами, но говорит понятное. Тело дженерика компилятор проверяет один раз для всех T сразу. «T может оказаться произвольным типом» значит: подставь string, и обещание T станет обещанием строки, а items[items.length - 1] под noUncheckedIndexedAccess это string | undefined. Полка T | undefined не помещается в полку T, потому что в ней есть undefined. Ринцвальд заглушил это знаком !:
const last = <T>(items: readonly T[]): T => items[items.length - 1]!;
const towers: string[] = [];
console.log(last(towers).toUpperCase());
TypeError: Cannot read properties of undefined (reading 'toUpperCase')
На канале с сообщениями работает. На пустом канале last вернул undefined, а тип обещал строку, и упало не в last, а у того, кто ему поверил. Честная сигнатура <T>(items: readonly T[]) => T | undefined переносит вопрос туда, где на него можно ответить. С ней тот же вызов last(towers).toUpperCase() не соберётся (Object is possibly 'undefined'), и вызывающий сам решит, что делать с пустым каналом.
Шифр, этап 4
Протокол получает каналы, которые помнят тип своих сообщений. Ты опишешь Channel<M>, конструктор канала, отправку, которая не меняет старый канал, а возвращает новый, честный last и маршрут с const-параметром. После этого этапа пинг в канал текстов не положить.
Практика
Дальше
Каналы готовы, Рыдкулл ушёл довольный. Библиотекарь снимает с верхней полки толстый каталог, раскрывает его на середине и кладёт перед тобой.
Пондер шёпотом переводит из-за стеллажа: читать вслух. Каталог Библиотеки это сигнатуры чужих функций: Array.map, Promise.then, pipe с десятком перегрузок. Как прочитать чужую сигнатуру и заранее сказать, что выведет компилятор, разберём в следующем уроке.