Раздел 46 · Effect: Тихая Академия

Шаблонные литеральные типы

middle~25 мин

без входаВойди, чтобы отмечать прогресс, вести заметки и решать задачи в редакторе. войти

Кадр лежит на столе Библиотеки рядом с картой: { 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 не даёт подставить пинг-отправителя. «Пинг же сообщение! Почему отправитель пингов не отправитель сообщений?» Ответ в следующем уроке, и он перевернёт то, что ты думал о подстановке.