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

условные типы≈ 11 минут

Здесь будет встроен видео-разбор темы (5–7 минут, YouTube / RuTube / VK Видео) — тем, кто лучше воспринимает на слух, текст ниже не обязателен.

Шаблонная строка из части I склеивала значение. Тот же синтаксис в позиции типа склеивает тип: `--${string}` описывает не одну строку, а все строки, начинающиеся с двух дефисов. Из объединений так собираются десятки имён, которых никто не выписывал руками, — и компилятор ловит опечатку в каждом.

Разделы темы:·

Обратные кавычки в позиции типа выглядят точно так же, как в позиции значения, и работают по тому же принципу: между кусками текста стоят дырки ${…}. Разница в том, что внутри дырки стоит не выражение, а тип, и результат — тоже тип.

ts
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

${string} означает «здесь любая строка». Тип CssVar — это не одно значение и не объединение из списка: это образец, которому строка обязана соответствовать. Литеральные типы, с которыми вы познакомились в главе про базовые типы, задавали конечный список; шаблонный задаёт форму.

Самое интересное начинается, когда в дырку попадает объединение. Компилятор перебирает все его варианты и подставляет каждый с каждым.

ts
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

В диагностике видно, во что развернулся 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"

Как и шаблон, они распределяются по объединению: каждый вариант обрабатывается отдельно, результат снова объединение.

ts
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Диагностика на последней ошибке не просто отказывает, а подсказывает ближайший вариант — компилятор видит, что "get" отличается от "GET" только регистром. В связке с шаблоном получается заготовка для имён обработчиков.

ts
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

В файле lib.es5.d.ts эти четыре типа объявлены странно: вместо тела у них стоит слово intrinsic. Это метка «реализация зашита в компилятор» — никакой записи на языке типов, которая меняла бы регистр букв, не существует. Попробуйте объявить такой тип у себя.

ts
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Компилятор откажет: слово зарезервировано за встроенными типами. Поэтому список из четырёх — исчерпывающий, пятого такого же преобразования (скажем, «в верблюжий регистр») в языке нет, его придётся собирать через infer и рекурсию.

Шаблон с дырками ${string} внутри — это уже проверка формата. Параметр функции можно объявить не как string, а как образец: тогда неподходящий литерал не пройдёт.

ts
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Второй вызов не проходит: в аргументе нет собаки. Обратите внимание на конец диагностики — компилятор печатает тип параметра ровно так, как вы его написали, обратными кавычками и всеми дырками. Это удобный признак: если в тексте ошибки видны ${string}, значит проверка шла по образцу, а не по списку литералов.

В дырку можно поставить не только string. Например, ${number} — и вот тут интуиция подводит.

ts
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Ругань пришла только на "16pxpx". Строку "16 px" компилятор пропустил — и это не баг, а буквальное следование правилу. Он отрезает от строки хвост "px", а оставшийся кусок проверяет ровно так же, как это сделал бы унарный плюс: годится всё, что превращается в конечное число, — кроме пустого остатка, его проверка отбрасывает отдельным условием. Number("16 ") — это 16, значит кусок «числовой». По той же причине пройдут " 16px" и "0x10px", а "Infinitypx" и "1_000px" — нет.

Шаблонный тип — не регулярное выражение. Он умеет только «текст, дырка, текст» и не знает ни повторов, ни классов символов, ни якорей.

До сих пор шаблон только собирал. Обратный ход — поставить шаблон справа от extends в условном типе и назвать дырки через infer. Тогда компилятор не проверяет строку, а разбирает её.

ts
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Из литерала "GET /users" получился кортеж ["GET", "/users"] — причём не тип «кортеж из двух строк», а кортеж из двух конкретных литералов. Перепутанный порядок компилятор замечает: на строке со swapped сразу две диагностики, и первая говорит, что "/users" не годится там, где ждали "GET".

Когда разделитель встречается в строке несколько раз, важно знать, где произойдёт разрез. Компилятор ищет первое вхождение разделителя, поэтому левая дырка забирает минимум, а правая — весь остаток.

ts
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Первая строка проходит, вторая — нет: A получил "a", а B — всё остальное вместе с дефисом внутри. Ведёт себя это как ленивый квантификатор в регулярных выражениях, и на рекурсивных разборах разница принципиальна.

Разрезав строку один раз, тип может позвать сам себя на остатке — так получается разбор произвольной длины. Классический пример: вытащить имена параметров из шаблона маршрута.

ts
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Первая ветка срабатывает, пока после двоеточия есть слэш, и отдаёт объединение «имя плюс разбор остатка». Вторая ловит последний параметр, третья заканчивает рекурсию пустым результатом. Всё это происходит на этапе компиляции: до браузера доедет обычный console.log.

Сам по себе UserParams бесполезен — он становится полезным, когда попадает в тип возвращаемого значения функции, которая эту строку разбирает по-настоящему.

ts
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Аргумент — обычная строка, а имена ключей результата компилятор вывел из неё сам. Опечатка в последней строке ловится сразу, и в диагностике видно посчитанный тип: Record<"userId" | "postId", string>. Ни одной из этих строк в коде функции нет.

Сопоставленный тип умеет не только проходить по ключам, но и переименовывать их через as. Именно здесь шаблонные типы применяются чаще всего: из полей объекта получаются имена обработчиков, геттеров или действий.

ts
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Ни одной диагностики — и это главное. Имена onThemeChange и onFontSizeChange нигде не объявлены: их собрал шаблон. При этом value в каждом обработчике получил свой тип — в первом строку, во втором число, — потому что State[K] взяло значение того же ключа, из которого сделано имя.

Пересечение keyof State & string нужно потому, что ключом объекта может быть и символ, а шаблонный тип с символом работать не умеет. Теперь испортим одну букву.

ts
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Диагностик стало две. Первая — про лишнее свойство, и в ней есть подсказка с правильным именем. Вторая, про неявный any у value, — следствие первой: раз ключ не совпал ни с одним известным, компилятору неоткуда взять тип аргумента. Такая пара ошибок вместо одной — обычное дело, и читать её нужно сверху вниз: почините первую, вторая исчезнет сама.

Если шаблон в as-клаузе вернёт never, ключ выпадет из результата. Это стандартный приём отбора полей — но он живёт в статье про сопоставленные типы.

Шаблонный тип проверяет литералы — то есть строки, которые компилятор видит в исходнике. Строка, пришедшая из формы, из сети или из localStorage, имеет тип string, и никакой образец её не пропустит.

ts
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Отказ здесь — правильное поведение, а не помеха: значение действительно может оказаться чем угодно, компилятор об этом честно говорит. Соблазн заткнуть его через as велик, и вот чем это кончается.

ts
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Ни одной диагностики — и падение при запуске. Собаки в строке нет, split вернул массив из одного элемента, обращение по индексу дало undefined. Это тот самый случай, когда компилятор молчит не потому, что всё хорошо, а потому, что ему велели молчать: проверять формат строки, пришедшей извне, придётся во время выполнения.

Второе ограничение — размер. Каждая дырка с объединением умножает количество вариантов, и на ста тысячах компилятор сдаётся.

ts
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Четырёхзначный код — десять тысяч литералов, компилятор их терпит. Пятизначный — сто тысяч, и приходит 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, поэтому опечатка проходит молча.

ts
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Одна строка: два объединения в дырках и двоеточие между ними. Компилятор развернёт это в четыре литерала.

tsлистинг · не запускается
Листинг только для чтения: этот код не запускается в браузере.

В диагностике будет видно всё посчитанное объединение: "user:created" | "user:deleted" | "order:created" | "order:deleted". Выписывать эти четыре строки руками не нужно — и когда к Entity добавится третья сущность, список обновится сам.

Расширение файла

Напишите тип Extension<S>, который из литерала с именем файла достаёт последнее расширение. Точек в имени может быть сколько угодно: из "report.final.pdf" должно получиться "pdf", а не "final.pdf".

ts
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Разрезаем по первой точке и зовём тип на остатке, пока точки не кончатся. Когда шаблон перестаёт совпадать, условный тип отдаёт строку целиком — это и есть расширение.

tsxлистинг · не запускается
Листинг только для чтения: этот код не запускается в браузере.

Рекурсия здесь обязательна именно потому, что первая дырка не жадная: за один шаг Rest получает "final.pdf", и только следующий заход добирается до "pdf". Имя без точек попадает сразу в ветку «иначе» и возвращается как есть.

Геттеры из конфига

Из типа конфига нужно получить тип объекта с методами getHost и getPort, возвращающими значения соответствующих полей. Сейчас сопоставленный тип копирует имена ключей как есть, и обе диагностики про это.

ts
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Ключ переименовывается через as, а имя собирается шаблоном с Capitalize. Пересечение с string нужно, чтобы в шаблон не попал символьный ключ.

tsxлистинг · не запускается
Листинг только для чтения: этот код не запускается в браузере.

Проверьте себя: поменяйте () => 443 на () => "443". Компилятор возразит, потому что Config[K] для ключа port — это number, и связь между именем метода и типом его результата сохранилась.