Кадр лежит на столе Библиотеки рядом с картой: { from: 'T-12a', to: 'T-1', glyphs: [...] }. В утреннем отчёте Магистрали все башни зелёные, и T-12a среди них тоже значится «на связи». Кто-то шлёт кадры от башни, которой нет, и отчёт об этом молчит.
Протокол проглотил кадр, потому что в типе Frame из урока 06 поле from это string. string это полка со всеми строками сразу: 'T-1', 'T-12a', 'мост' и пустая строка стоят на ней рядом. Нужна полка поуже: «буква T, дефис и номер». В прошлом уроке ты разбирал типы образцом через infer. Сегодня образцом станет сама строка.
Строки по шаблону
Шаблонная строка в JavaScript склеивает значения: `T-${12}` даёт 'T-12'. Шаблонный литеральный тип склеивает типы, и результат это множество строк.
Начни с конечного множества. Ставни из урока 05 бывают 'open' и 'closed':
type Shutter = 'open' | 'closed';
type Pair = `${Shutter}-${Shutter}`;
// 'open-open' | 'open-closed' | 'closed-open' | 'closed-closed'
Каждое место в шаблоне перебирает своё объединение, и TypeScript собирает все сочетания: два на два, четыре строки. Ведомость Библиотекаря, только строки вместо полей.
Вместо объединения литералов в шаблон можно поставить number, string или bigint. Тогда множество бесконечно, но у него есть форма:
type TowerCode = `T-${number}`;
type Route = `${TowerCode}->${TowerCode}`;
const home: TowerCode = 'T-1';
const ghost: TowerCode = 'T-12a';
towers.ts(5,7): error TS2322: Type '"T-12a"' is not assignable to type '`T-${number}`'.
Кадр от T-12a больше не пройдёт мимо компилятора, если код башни пришёл литералом. А маршрут Route это два кода через стрелку: 'T-7->T-8' подходит, 'T-7->мост' нет.
Перевод с Ук
Чаще всего эта ошибка встаёт на строке, которая пришла снаружи.
const raw: string = 'T-3';
const code: TowerCode = raw;
towers.ts(8,7): error TS2322: Type 'string' is not assignable to type '`T-${number}`'.
Ошибка в одну строку, но читать её стоит внимательно. Тип в обратных кавычках это шаблон: TypeScript печатает его так же, как ты его написал. Слева string, справа шаблон. Не «строка 'T-3' не подходит», а «любая строка не подходит»: компилятор не смотрит, что лежит в raw прямо сейчас. Тип raw это string, вся большая полка, а маленькая полка лежит внутри неё и не вмещает её целиком. Значение 'T-3' подошло бы, тип string нет.
Чтобы строка с провода стала кодом башни, нужна проверка в рантайме и предикат is из урока 06. Какой проверкой, ниже, и это интереснее, чем кажется.
Что TypeScript считает числом
${number} в шаблоне выглядит как «цифры». Проверь на кадрах, которые могли прийти по проводу:
| Строка | `T-${number}` |
|---|---|
'T-12' | подходит |
'T-12a' | нет |
'T-' | нет |
'T-1_000' | нет |
'T-1.5' | подходит |
'T-1e3' | подходит |
'T--4' | подходит |
'T-0x1F' | подходит |
'T- 7' | подходит |
'T-1 ' | подходит |
Нижняя половина таблицы удивляет. Правило у TypeScript такое: он берёт текст на месте ${number} и проверяет его так же, как унарный плюс в JavaScript. Текст не пустой и +текст даёт конечное число, значит, подходит. А унарный плюс снисходителен: срезает пробелы по краям, читает дробь, степень, минус и шестнадцатеричную запись. +'1_000' даёт NaN, потому что подчёркивание в числах разрешено только в коде, а не в строке.
Для протокола это значит: тип это грубое сито. Оно отсекает T-12a и мост, но пропускает T-1.5 и T-1 с пробелом на конце. Башни с номером полтора не бывает, а пробел в коде башни это чей-то сломанный передатчик.
Поэтому рантайм-проверка строже типа:
const isTowerCode = (value: string): value is TowerCode => /^T-\d+$/.test(value);
^ и $ прибивают выражение к началу и концу строки, \d+ значит «одна или больше цифр». Пробел, точка, буква не пройдут. Предикат обещает компилятору TowerCode, а регулярное выражение отдаёт ему меньше, чем тип разрешает: каждое значение, которое прошло проверку, лежит на полке TowerCode, но не наоборот. Так и должно быть. Тип говорит, какой формы строка, граница проверяет, что строка правда такая.
Литералы сквозь дженерик
Маршрут из двух кодов собирается функцией. Хочется, чтобы тип помнил, какие это башни:
const routeOf = <A extends TowerCode, B extends TowerCode>(from: A, to: B): `${A}->${B}` =>
`${from}->${to}`;
const line = routeOf('T-1', 'T-2');
// 'T-1->T-2'
Ограничение extends TowerCode держит литералы, как extends string держал их в уроке 09: A выводится как 'T-1', а не как весь TowerCode. Тип результата это шаблон из двух параметров, и в нём подставлены те же литералы. А routeOf('T-1', 'мост') не соберётся вовсе:
route.ts(5,31): error TS2345: Argument of type '"мост"' is not assignable to parameter of type '`T-${number}`'.
infer внутри шаблона
Шаблон годится и как образец для infer. Номер башни можно достать прямо в типе:
type Raw<C> = C extends `T-${infer N}` ? N : never;
type Twelve = Raw<'T-12'>;
// '12'
Вышла строка '12'. Чтобы получить число, добавь ограничение прямо в infer, так можно с TypeScript 4.8:
type TowerNumber<C extends TowerCode> = C extends `T-${infer N extends number}` ? N : never;
type Number12 = TowerNumber<'T-12'>;
// 12
infer N extends number значит «назови это место N, но только если оно читается как число, и отдай его числом». Здесь TypeScript строже, чем при проверке шаблона: литерал он отдаёт, только если число, записанное обратно, совпадает с текстом. TowerNumber<'T-1.5'> даёт 1.5, а TowerNumber<'T-007'> даёт общий number, потому что 7 записывается как '7', а не '007'. Для всего TowerCode целиком ответ тоже number.
Разбор строк, умеренно
Маршрут можно разобрать так же:
type Ends<R> = R extends `${infer From}->${infer To}` ? [From, To] : never;
type Line = Ends<'T-1->T-2'>;
// ['T-1', 'T-2']
type Long = Ends<'T-1->T-2->T-3'>;
// ['T-1', 'T-2->T-3']
Посмотри на второй ответ. Первый infer перед литералом -> берёт самый короткий кусок до первой стрелки, всё остальное уходит во второй. Образец не проверяет, что стрелка одна: он только режет. Можно дописать рекурсию, которая разрежет маршрут на все башни и проверит каждую, и такие типы пишут. Здесь курс остановится. Разбор строк в типах быстро превращается в головоломку: компилятор тратит на него время, а ошибки в нём не прочитает даже Библиотекарь. Типы описывают форму, разбирает и проверяет рантайм.
Рантайм-разбор маршрута строже:
const [from, to, ...rest] = value.split('->');
Ровно два куска, и каждый проходит isTowerCode. С флагом noUncheckedIndexedAccess у from и to тип string | undefined, и компилятор не даст забыть, что кусков могло оказаться меньше.
Где тут дыра
Ринцвальд прочитал про ${number} и написал проверку, которая «делает то же, что тип»:
const isTowerCode = (value: string): value is TowerCode =>
value.startsWith('T-') && !Number.isNaN(Number(value.slice(2)));
На его двух примерах правда работает. А на соседних:
"T-12" true
"T-12a" false
"T-" true
"T-1.5" true
"T- 7" true
"T-1 " true
'T-' прошёл: Number('') это 0, а не NaN. Протокол Ринцвальда считает пустой код башни нулевой башней и шлёт ей кадры. 'T-1.5' и 'T- 7' тоже прошли. Ринцвальд не ошибся в коде, он честно повторил сито типа в рантайме, а сито дырявое. Тип описывает форму, которую удобно проверить компилятору; граница проверяет форму, которая нужна протоколу. Это разные вещи, и вторая всегда строже.
Шифр, этап 7
Протокол учится отличать настоящие коды башен от призрачных. Код и маршрут описаны шаблонными типами, номер башни читается прямо из типа, а строгая проверка и разбор маршрута стоят на границе, там, где строки приходят с провода. Кадр от T-12a больше не войдёт в протокол незамеченным.
Практика
Глубже в архиве
Как разбирать строки в типах рекурсией и где у этого предел, показывают уроки «Программирование на типах · фундамент» и «Программирование на типах · глубже». Читай их как экскурсию: в протоколе башни такой разбор не нужен.
Дальше
Утром Ринцвальд прибегает с вопросом, которого боится сам. У него отправитель, который умеет слать только пинги. Функция ретрансляции ждёт отправителя любых сообщений, и TypeScript не даёт подставить пинг-отправителя. «Пинг же сообщение! Почему отправитель пингов не отправитель сообщений?» Ответ в следующем уроке, и он перевернёт то, что ты думал о подстановке.