Шаблонные литеральные типы: вычисление, разворачивание и разбор строковых типов
Шаблонные литеральные типы строят строковые типы интерполяцией. Интерполяция объединений даёт полное декартово произведение — комбинаторный взрыв. Встроенные типы меняют регистр, а `infer` внутри образца разбирает строки. Вывод образца нежадный на первом разделителе.
Вы пишете app.get("/users/:id", handler), и редактор типизирует req.params как { id: string } — он вычитал сегмент-двоеточие из строки маршрута на уровне типов. Или вы выдаёте тему CSS-in-JS, и "colors.brand.primary" автодополняется из вложенного объекта. И то, и другое — шаблонные литеральные типы: строковые типы, в которые можно интерполировать, разворачивать по объединениям и — с infer внутри образца — разбирать посимвольно. TypeScript превратил систему типов в крошечный обработчик строк.
К концу урока вы поймёте обе стороны этой мощи: как извлечь параметры маршрута через infer внутри образца — и в какой именно момент одно лишнее объединение взрывает лимит компилятора в 100 000 членов.
Интерполяция и декартово произведение объединений
Зачем генерировать строковые типы вместо того, чтобы просто перечислить их? Потому что набор допустимых строк часто растёт вместе с моделью данных — локаль вида "en-US" должна выводиться из типов Lang и Region, которые вы уже поддерживаете: добавьте "fr" в Lang — и "fr-US", "fr-GB" появятся автоматически без правки типа локали.
Шаблонный литеральный тип выглядит как строка-шаблон JS, но оперирует типами. Интерполяция одного литерала предсказуема; интерполяция объединения разворачивается в каждую комбинацию — полное декартово произведение:
type Greeting = `Hello, ${string}!`; // любая строка "Hello, ...!"
type Hi = Greeting & "Hello, Sam!"; // ^? "Hello, Sam!" (это член)
type Lang = "en" | "ru";
type Region = "US" | "GB";
type Locale = `${Lang}-${Region}`;
// ^? "en-US" | "en-GB" | "ru-US" | "ru-GB" — 2 x 2 = 4 членаКаждый слот интерполяции умножает число членов. Два двухчленных объединения дают 4; добавьте третье трёхчленное — и получите 12; рост мультипликативный. Это главный подвох: шаблон над несколькими большими объединениями может породить тысячи членов и замедлить или застопорить проверщик.
▸Частая ошибка
Декартово произведение — самый частый способ взорвать время компиляции шаблонными литералами. `${A}${B}${C}`, где каждое — объединение из 50 членов, это 50 x 50 x 50 = 125 000 строковых литералов — TypeScript ограничивает объединения (сейчас ~100 тыс. членов) и сверх того выдаст «Expression produces a union type that is too complex to represent» или просто будет еле ползти. Держите интерполируемые объединения маленькими или переносите работу в разбор (infer) вместо генерации всего пространства.
Встроенные манипуляторы регистра
Четыре встроенных (реализованных компилятором) строковых типа меняют регистр, и определения для них на уровне библиотеки вы написать не смогли бы:
type U = Uppercase<"hello">; // ^? "HELLO"
type L = Lowercase<"HELLO">; // ^? "hello"
type C = Capitalize<"hello">; // ^? "Hello"
type N = Uncapitalize<"Hello">; // ^? "hello"Это кусочки за переименованием ключей (get${Capitalize<K>} из прошлого урока) и за типизированными построителями имён событий.
Разбор через infer внутри шаблонного образца
Настоящая мощь: поместите infer внутрь шаблонного литерала в части extends условия, чтобы вытащить подстроки. Чтобы извлечь параметр маршрута:
type RouteParam<S extends string> =
S extends `${string}:${infer Param}/${string}`
? Param
: S extends `${string}:${infer Param}`
? Param
: never;
type P1 = RouteParam<"/users/:id">; // ^? "id"
type P2 = RouteParam<"/users/:id/posts">; // ^? "id"Для нескольких параметров вы рекурсируете, собирая каждый в объединение — строительный блок для полностью типизированных роутеров. Разбиение строки с разделителями — та же идея:
type Split<S extends string, D extends string> =
S extends `${infer Head}${D}${infer Tail}`
? [Head, ...Split<Tail, D>]
: [S];
type Parts = Split<"a-b-c", "-">;
// ^? ["a", "b", "c"]Вывод нежадный на первом разделителе
Когда образец имеет ${infer Head}<разделитель>${infer Tail}, Head сопоставляется как можно меньше — до первого вхождения разделителя, не до последнего:
type FirstSeg<S extends string> =
S extends `${infer Head}-${infer Tail}` ? [Head, Tail] : [S];
type R = FirstSeg<"a-b-c">;
// ^? ["a", "b-c"] — Head останавливается на ПЕРВОМ '-', Tail берёт остальноеПоэтому Split выше отслаивает по одному сегменту за рекурсию слева. Если бы вы предположили, что Head жадно проглотит "a-b", разрешённый тип вас удивил бы — правило нежадности с самым левым разделителем и делает рекурсивный разбор слева направо работающим.
Цена: парсер в системе типов — это парсер, который вы не можете отладить
Шаблонные литералы — это точка, где TypeScript перестаёт быть type-checker’ом и становится языком обработки строк, и именно здесь нужна наибольшая дисциплина. Стоимость декартова произведения реальна и крута: один шаблон, интерполирующий четыре сегмента, каждый по алфавиту, — это 26⁴ ≈ 457 000 членов, далеко за потолком в 100 000 членов, так что tsc либо выдаёт Expression produces a union type that is too complex to represent, либо, при добавлении рекурсии, исчерпывает кучу и падает с OOM (дефолт Node 2–4 ГБ). Даже не доходя до потолка, рекурсивный 50 уровней) и откатывающаяся к Split/парсер над длинной строкой — это нехвостовая рекурсия, упирающаяся в стену глубины инстанцирования (string, молча отбрасывая точность, ради которой вы писали тип.
Senior-критерий: разобранный шаблонный тип даёт сквозную строковую безопасность — :id в роуте проверяется против req.params обработчика без стоимости в рантайме и без дублирования объявления. Это многого стоит на границе библиотеки, где формат строки — это API (роутер, типизированный i18n-ключ, SQL-подобный DSL). Внутри прикладного кода это редко оправдано: 40-строчный рекурсивный шаблонный парсер нечитаем для следующего инженера, порождает сообщения об ошибках, указывающие на машинерию типов, а не на ошибку человека, и облагает налогом каждое нажатие в редакторе. Когда вы ловите себя на разборе нетривиальной грамматики на уровне типов, это сигнал перенести её в шаг сборки/кодогенерации — сгенерируйте карту роутов, объединение i18n-ключей или типы запросов как плоские именованные типы из источника истины. Сгенерированный type Routes = "/users/:id" | ... грепается, чисто наводится и компилируется мгновенно; рекурсивный шаблон, его выводящий, — парсер, через который не пройдёт ни один отладчик. Тянитесь к шаблонам, когда сама строка — это контракт; тянитесь к кодогенерации, когда строку нужно разбирать.
▸lesson.inset.warning
Режим отказа хуже, чем медленный, — это молчаливая потеря точности. Рекурсивный шаблонный парсер, превышающий предел инстанцирования в ~50 уровней, не выдаёт ошибку; он тихо разрешается в string, так что роут, типизированный RouteParams<"/a/:x/b/:y/c/:z/..."> за пределом, даёт вам string для объекта параметров, и каждая точка вызова молча теряет свою проверку. Ловите это через tsc --extendedDiagnostics: шаблоноёмкий файл, загоняющий Instantiation count в миллионы, а Check time вверх на 5–20с, — это признак, и редактор деградирует в ногу — наведения и автодополнение на затронутом файле падают с мгновенных до многосекундных, потому что IntelliSense выполняет то же инстанцирование, чтобы отрисовать подсказку. Увидев это, ограничьте глубину рекурсии или перенесите разбор в кодогенерацию до релиза.
Во что разрешается FirstSeg<'a-b-c'>, где FirstSeg<S> = S extends `${infer Head}-${infer Tail}` ? [Head, Tail] : [S]?
Упорядочьте, как TypeScript разрешает тип Locale `${Lang}-${Region}` для Lang = 'en' | 'ru' и Region = 'US' | 'GB':
- 1 Определяет два слота интерполяции: объединение Lang и объединение Region
- 2 Дистрибутирует первый слот: порождает ветки 'en-...' и 'ru-...'
- 3 Для каждой дистрибутирует второй слот по 'US' и 'GB'
- 4 Собирает декартово произведение в 'en-US' | 'en-GB' | 'ru-US' | 'ru-GB'
- 01Что происходит при интерполяции объединений в шаблонный литеральный тип и почему это подвох по производительности?
- 02Какие четыре встроенных строковых типа и как разобрать параметр маршрута или разбить строку через infer внутри шаблона?
- 03Вывод шаблонного образца жадный или нежадный и во что разрешается FirstSeg<'a-b-c'> для FirstSeg<S> = S extends `${infer Head}-${infer Tail}` ? [Head, Tail] : [S]?
Шаблонные литеральные типы завершили набор инструментов глубокого погружения в систему типов: теперь вы можете вычислять строковые типы интерполяцией, уважаете мультипликативный взрыв декартова произведения и можете разбирать строки через infer внутри образца по правилу нежадности с первым разделителем. Условия, infer, отображённые типы, ремаппинг ключей и шаблоны — пять примитивов, делающих типы программируемыми. В следующем юните — программирование на уровне типов — они складываются в рекурсию, дистрибутивную логику, служебные типы с нуля и даже арифметику типов, и пересматриваются пределы производительности, в которые эти примитивы упираются на масштабе. Теперь, встречая тип роутера, автодополняющий :id из строки пути, вы узнаёте infer внутри шаблонного образца — и знаете: прежде чем отправить рекурсивную версию в прод, открыть tsc --extendedDiagnostics.
Практика
Начни сверху. Задачи идут от простого к сложному: вспомнить факт, применить к случаю, затем senior-уровень. Открой, попробуй, потом открой ответ.
Что-то непонятно?
Задай вопрос по этому уроку. Вопросы анонимны и попадают напрямую автору — урок станет лучше.
Примени это
Примени этот урок в реальном проекте.