Шаблонные литеральные типы
Здесь будет встроен видео-разбор темы (5–7 минут, YouTube / RuTube / VK Видео) — тем, кто лучше воспринимает на слух, текст ниже не обязателен.
Шаблонная строка из части I склеивала значение. Тот же синтаксис в позиции типа склеивает тип: `--${string}` описывает не одну строку, а все строки, начинающиеся с двух дефисов. Из объединений так собираются десятки имён, которых никто не выписывал руками, — и компилятор ловит опечатку в каждом.
Обратные кавычки в позиции типа выглядят точно так же, как в позиции значения, и работают по тому же принципу: между кусками текста стоят дырки ${…}. Разница в том, что внутри дырки стоит не выражение, а тип, и результат — тоже тип.
${string} означает «здесь любая строка». Тип CssVar — это не одно значение и не объединение из списка: это образец, которому строка обязана соответствовать. Литеральные типы, с которыми вы познакомились в главе про базовые типы, задавали конечный список; шаблонный задаёт форму.
Самое интересное начинается, когда в дырку попадает объединение. Компилятор перебирает все его варианты и подставляет каждый с каждым.
В диагностике видно, во что развернулся Corner: "top-left" | "top-right" | "bottom-left" | "bottom-right". Это не какая-то отложенная проверка по образцу — компилятор честно построил объединение из четырёх литералов и сверился с ним.
Две дырки по три варианта дают девять литералов, три по три — двадцать семь. Произведение растёт быстро; в разделе про границы будет видно, где оно упирается в потолок.
Собирать имена вроде onClick из куска click мешает регистр: в объединении лежит "click", а нужно "Click". Для этого в стандартной библиотеке есть четыре типа, и все они работают только со строковыми типами.
| Тип | Что делает | Пример |
|---|---|---|
Uppercase | все буквы заглавные | Uppercase<"fontSize"> → "FONTSIZE" |
Lowercase | все буквы строчные | Lowercase<"fontSize"> → "fontsize" |
Capitalize | первая буква заглавная | Capitalize<"fontSize"> → "FontSize" |
Uncapitalize | первая буква строчная | Uncapitalize<"FontSize"> → "fontSize" |
Как и шаблон, они распределяются по объединению: каждый вариант обрабатывается отдельно, результат снова объединение.
Диагностика на последней ошибке не просто отказывает, а подсказывает ближайший вариант — компилятор видит, что "get" отличается от "GET" только регистром. В связке с шаблоном получается заготовка для имён обработчиков.
В файле lib.es5.d.ts эти четыре типа объявлены странно: вместо тела у них стоит слово intrinsic. Это метка «реализация зашита в компилятор» — никакой записи на языке типов, которая меняла бы регистр букв, не существует. Попробуйте объявить такой тип у себя.
Компилятор откажет: слово зарезервировано за встроенными типами. Поэтому список из четырёх — исчерпывающий, пятого такого же преобразования (скажем, «в верблюжий регистр») в языке нет, его придётся собирать через infer и рекурсию.
Шаблон с дырками ${string} внутри — это уже проверка формата. Параметр функции можно объявить не как string, а как образец: тогда неподходящий литерал не пройдёт.
Второй вызов не проходит: в аргументе нет собаки. Обратите внимание на конец диагностики — компилятор печатает тип параметра ровно так, как вы его написали, обратными кавычками и всеми дырками. Это удобный признак: если в тексте ошибки видны ${string}, значит проверка шла по образцу, а не по списку литералов.
В дырку можно поставить не только string. Например, ${number} — и вот тут интуиция подводит.
Ругань пришла только на "16pxpx". Строку "16 px" компилятор пропустил — и это не баг, а буквальное следование правилу. Он отрезает от строки хвост "px", а оставшийся кусок проверяет ровно так же, как это сделал бы унарный плюс: годится всё, что превращается в конечное число, — кроме пустого остатка, его проверка отбрасывает отдельным условием. Number("16 ") — это 16, значит кусок «числовой». По той же причине пройдут " 16px" и "0x10px", а "Infinitypx" и "1_000px" — нет.
Шаблонный тип — не регулярное выражение. Он умеет только «текст, дырка, текст» и не знает ни повторов, ни классов символов, ни якорей.
До сих пор шаблон только собирал. Обратный ход — поставить шаблон справа от extends в условном типе и назвать дырки через infer. Тогда компилятор не проверяет строку, а разбирает её.
Из литерала "GET /users" получился кортеж ["GET", "/users"] — причём не тип «кортеж из двух строк», а кортеж из двух конкретных литералов. Перепутанный порядок компилятор замечает: на строке со swapped сразу две диагностики, и первая говорит, что "/users" не годится там, где ждали "GET".
Когда разделитель встречается в строке несколько раз, важно знать, где произойдёт разрез. Компилятор ищет первое вхождение разделителя, поэтому левая дырка забирает минимум, а правая — весь остаток.
Первая строка проходит, вторая — нет: A получил "a", а B — всё остальное вместе с дефисом внутри. Ведёт себя это как ленивый квантификатор в регулярных выражениях, и на рекурсивных разборах разница принципиальна.
Разрезав строку один раз, тип может позвать сам себя на остатке — так получается разбор произвольной длины. Классический пример: вытащить имена параметров из шаблона маршрута.
Первая ветка срабатывает, пока после двоеточия есть слэш, и отдаёт объединение «имя плюс разбор остатка». Вторая ловит последний параметр, третья заканчивает рекурсию пустым результатом. Всё это происходит на этапе компиляции: до браузера доедет обычный console.log.
Сам по себе UserParams бесполезен — он становится полезным, когда попадает в тип возвращаемого значения функции, которая эту строку разбирает по-настоящему.
Аргумент — обычная строка, а имена ключей результата компилятор вывел из неё сам. Опечатка в последней строке ловится сразу, и в диагностике видно посчитанный тип: Record<"userId" | "postId", string>. Ни одной из этих строк в коде функции нет.
Сопоставленный тип умеет не только проходить по ключам, но и переименовывать их через as. Именно здесь шаблонные типы применяются чаще всего: из полей объекта получаются имена обработчиков, геттеров или действий.
Ни одной диагностики — и это главное. Имена onThemeChange и onFontSizeChange нигде не объявлены: их собрал шаблон. При этом value в каждом обработчике получил свой тип — в первом строку, во втором число, — потому что State[K] взяло значение того же ключа, из которого сделано имя.
Пересечение keyof State & string нужно потому, что ключом объекта может быть и символ, а шаблонный тип с символом работать не умеет. Теперь испортим одну букву.
Диагностик стало две. Первая — про лишнее свойство, и в ней есть подсказка с правильным именем. Вторая, про неявный any у value, — следствие первой: раз ключ не совпал ни с одним известным, компилятору неоткуда взять тип аргумента. Такая пара ошибок вместо одной — обычное дело, и читать её нужно сверху вниз: почините первую, вторая исчезнет сама.
Если шаблон в as-клаузе вернёт never, ключ выпадет из результата. Это стандартный приём отбора полей — но он живёт в статье про сопоставленные типы.
Шаблонный тип проверяет литералы — то есть строки, которые компилятор видит в исходнике. Строка, пришедшая из формы, из сети или из localStorage, имеет тип string, и никакой образец её не пропустит.
Отказ здесь — правильное поведение, а не помеха: значение действительно может оказаться чем угодно, компилятор об этом честно говорит. Соблазн заткнуть его через as велик, и вот чем это кончается.
Ни одной диагностики — и падение при запуске. Собаки в строке нет, split вернул массив из одного элемента, обращение по индексу дало undefined. Это тот самый случай, когда компилятор молчит не потому, что всё хорошо, а потому, что ему велели молчать: проверять формат строки, пришедшей извне, придётся во время выполнения.
Второе ограничение — размер. Каждая дырка с объединением умножает количество вариантов, и на ста тысячах компилятор сдаётся.
Четырёхзначный код — десять тысяч литералов, компилятор их терпит. Пятизначный — сто тысяч, и приходит TS2590. Заметьте, где именно: диагностика стоит на строке объявления Five, а не на месте использования. Тип не «сломался при проверке» — он вообще не смог быть построен.
| Что нужно | Шаблонный тип | Проверка во время работы |
|---|---|---|
| Опечатка в имени, известном заранее | ловит до запуска | заметит только в проде |
| Строка пришла из формы или сети | бесполезен: её тип — string | единственный рабочий способ |
| Формат из сотен тысяч вариантов | TS2590, тип не строится | регулярное выражение справится |
Отсюда правило: шаблонные типы хороши там, где множество имён конечно и известно при написании кода, — события, ключи, маршруты, CSS-переменные. На границе программы их место занимает обычная проверка значения.
- Обратные кавычки в позиции типа собирают не строку, а тип:
`--${string}`— образец, которому обязано соответствовать значение. - Объединение в дырке разворачивается произведением: каждый вариант с каждым, и результат — обычное объединение литералов.
Uppercase,Lowercase,CapitalizeиUncapitalizeзашиты в компилятор; пятого такого преобразования в языке нет.- Шаблон справа от
extendsвместе сinferразбирает строку на части, а рекурсия позволяет пройти её до конца. - В as-клаузе сопоставленного типа шаблон делает имена свойств: из ключа
fontSizeполучаетсяonFontSizeChange, и связь с типом значения не теряется. - Проверка работает только над литералами: строка из формы имеет тип
string, а сто тысяч вариантов упираются в TS2590.
1/4Объявлен тип `${"top" | "bottom"}-${"left" | "right" | "center"}`. Сколько строковых литералов в нём окажется?
2/4Тип type Px = `${number}px`. Какое из трёх значений компилятор пропустит без единой диагностики?
3/4В сопоставленном типе ключи переименованы шаблоном `on${Capitalize<K>}Change`. Какое имя получится из ключа fontSize?
4/4У функции параметр объявлен как `${string}@${string}.${string}`. В неё передают переменную, объявленную как const s: string, со значением "rico@java-script.pro". Что произойдёт?
Имена событий из двух объединений
Есть два объединения: сущности и действия. Нужен тип имени события вида "user:created" — все сочетания, но только они. Сейчас Event объявлен как string, поэтому опечатка проходит молча.
Одна строка: два объединения в дырках и двоеточие между ними. Компилятор развернёт это в четыре литерала.
В диагностике будет видно всё посчитанное объединение: "user:created" | "user:deleted" | "order:created" | "order:deleted". Выписывать эти четыре строки руками не нужно — и когда к Entity добавится третья сущность, список обновится сам.
Расширение файла
Напишите тип Extension<S>, который из литерала с именем файла достаёт последнее расширение. Точек в имени может быть сколько угодно: из "report.final.pdf" должно получиться "pdf", а не "final.pdf".
Разрезаем по первой точке и зовём тип на остатке, пока точки не кончатся. Когда шаблон перестаёт совпадать, условный тип отдаёт строку целиком — это и есть расширение.
Рекурсия здесь обязательна именно потому, что первая дырка не жадная: за один шаг Rest получает "final.pdf", и только следующий заход добирается до "pdf". Имя без точек попадает сразу в ветку «иначе» и возвращается как есть.
Геттеры из конфига
Из типа конфига нужно получить тип объекта с методами getHost и getPort, возвращающими значения соответствующих полей. Сейчас сопоставленный тип копирует имена ключей как есть, и обе диагностики про это.
Ключ переименовывается через as, а имя собирается шаблоном с Capitalize. Пересечение с string нужно, чтобы в шаблон не попал символьный ключ.
Проверьте себя: поменяйте () => 443 на () => "443". Компилятор возразит, потому что Config[K] для ключа port — это number, и связь между именем метода и типом его результата сохранилась.