Глава 21. Шаблонные литеральные типы
Глава 21. Шаблонные литеральные типы
Имена, выведенные типом
В любом достаточно большом приложении заводится угол, который старшие инженеры обходят с опаской. Это угол строк, которые на самом деле не строки, а команды, имена, маршруты, ключи — структура, замаскированная под текст. Эмиттер событий, у которого "order.created" и "order.shipped" — не просто строки, а контракт. Роутер, в котором /orders/:id прячет параметр id. Слой, где из имени поля total положено вывести имя обработчика onTotalChange. Такой код принято звать stringly-typed — типизированным строками, и звать с презрением: строка проходит везде, компилятор молчит, а опечатка "order.creted" всплывает в проде.
Реакция инженера со статическим бэкграундом предсказуема. Строки опасны — значит, надо вынести смысл из строки куда-нибудь, где его видит компилятор. Завести enum событий. Разобрать маршрут регуляркой в рантайме. Сгенерировать обработчики кодогенератором или достать рефлексией. Всё это — способы сказать одно: «система типов про строки не знает ничего, поэтому будем городить вокруг них леса».
Теперь посмотрите на это.
type EventName = "order.created" | "order.shipped";
type Handler = `on:${EventName}`;
// Handler = "on:order.created" | "on:order.shipped"
Тип Handler не объявлен руками. Он вычислен из EventName — компилятор взял каждый член объединения и подставил в строковый шаблон. А вот обратное движение:
type Domain<T> = T extends `${infer D}.${infer _}` ? D : never;
type D = Domain<"order.shipped">; // "order"
Здесь тип разобрал строку: нашёл точку, вынул всё до неё и вернул "order" — на уровне типов, во время компиляции, без единой строчки рантайма. Система типов, оказывается, умеет и строить строки, и разбирать их. Угол, который полагалось обходить, внезапно стал проверяемым. Остаётся понять, как это устроено и где у этого устройства края.
Шаблонный литеральный тип
Начнём с литеральных типов из главы 8. Тип "order.created" — это не string, а ровно одна конкретная строка; значение либо равно ей, либо нет. Шаблонный литеральный тип (template literal type) — это литеральный строковый тип со слотами интерполяции, синтаксически неотличимый от шаблонной строки JavaScript, только живущий на уровне типов:
type Greeting = `Привет, ${string}`;
const a: Greeting = "Привет, мир"; // ок
const b: Greeting = "Пока, мир"; // Type '"Пока, мир"' is not assignable to type '`Привет, ${string}`'
Greeting описывает не одну строку, а форму строки: «Привет, и дальше что угодно». В слот можно подставить не только string, но и любой тип, у которого есть строковое представление, — number, bigint, boolean, литералы:
type Csspx = `${number}px`;
const m: Csspx = "16px"; // ок
const n: Csspx = "16em"; // ошибка: не подходит под `${number}px`
Интереснее, что происходит, когда в слот попадает не один тип, а union — объединение. Шаблон распределяется (distributes) по объединению: для каждого члена строится своя строка, результат — объединение всех строк.
type Method = "GET" | "POST";
type Path = "/orders" | "/orders/:id";
type Route = `${Method} ${Path}`;
// "GET /orders" | "GET /orders/:id" | "POST /orders" | "POST /orders/:id"
Один шаблон с двумя слотами породил декартово произведение — все четыре комбинации. Это и есть генератор строковых типов: вместо того чтобы выписывать четыре маршрута руками и ловить опечатки глазами, мы задали два множества и правило их склейки. Добавится метод DELETE — все его маршруты появятся сами.
И ровно здесь же притаилась первая ловушка, которую честнее показать сразу, а не прятать в конце. Декартово произведение растёт как произведение. Два слота по два значения дали четыре. Четыре слота по десять дадут десять тысяч.
type Hex = "0" | "1" | "2" | "3" | "4" | "5" | "6" | "7" | "8" | "9" | "a" | "b" | "c" | "d" | "e" | "f";
type Byte = `${Hex}${Hex}`; // 256 членов объединения
type Color = `#${Byte}${Byte}${Byte}`; // 16 777 216 — компилятор не дойдёт
// Expression produces a union type that is too complex to represent.
Компилятор сдаётся явно, и это милосердие: он мог бы вместо ошибки молча сжирать память и время сборки. Запомним границу — к ней вернёмся в разделе про острые края. Пока достаточно того, что шаблон умеет порождать объединения механически, и что эта механика — палка о двух концах.
Встроенные преобразования регистра
Чтобы строить из строк имена, одного склеивания мало — нужно ещё менять регистр. onClick от click, getTotal от total. В обычной системе типов такого не сделать: регистр — свойство конкретных символов, а не структуры. Поэтому TypeScript встроил четыре интринсика (intrinsic — примитив, реализованный внутри компилятора, а не выраженный через сам язык типов): Uppercase, Lowercase, Capitalize, Uncapitalize.
type A = Uppercase<"order">; // "ORDER"
type B = Lowercase<"ORDER">; // "order"
type C = Capitalize<"order">; // "Order"
type D = Uncapitalize<"Order">; // "order"
Сами по себе они скучны. Сила появляется, когда их складывают с шаблоном — тогда из строкового слота выходит правильно оформленное имя:
type Field = "total" | "status";
type HandlerName = `on${Capitalize<Field>}Change`;
// "onTotalChange" | "onStatusChange"
Объединение Field распределилось по шаблону, каждый член прошёл через Capitalize, и на выходе — два имени обработчиков, выведенные из имён полей. Никакого кодогенератора: имена существуют как типы ровно потому, что существуют поля. Переименуете поле — имя обработчика поедет за ним или сломается на проверке. Это первая половина суперсилы: из данных вывести имена. Вторая половина — обратная: из имени вынуть данные.
Разбор строк через infer
В главе 19 infer объявлял типовую переменную внутри условного типа — «сопоставь с образцом и вынь то, что встало на это место». В шаблоне infer работает так же, но образцом служит строка, и сопоставление идёт по разделителям:
type Head<T> = T extends `${infer H}.${infer _}` ? H : never;
type X = Head<"order.created">; // "order"
type Y = Head<"payment.failed">; // "payment"
type Z = Head<"plain">; // never — точки нет, образец не совпал
Образец `${infer H}.${infer _}` читается так: строка должна состоять из чего-то, точки и ещё чего-то; назови первую часть H, вторую — _. Компилятор находит первую точку, режет по ней и связывает переменные. Если точки нет — ветвь extends не выполняется, возвращается never.
Дальше извлечённую часть можно тут же преобразовать. Соберём из ключа "order.created" имя метода-обработчика:
type Handler<T extends string> =
T extends `${infer Domain}.${infer Action}`
? `on${Capitalize<Domain>}${Capitalize<Action>}`
: never;
type H = Handler<"order.created">; // "onOrderCreated"
Строка разобрана на Domain и Action, обе части капитализированы и склеены обратно в новое имя — целиком на уровне типов. Это уже маленький компилятор: вход — строка, выход — другая строка, выведенная по правилу.
Пока разделитель один. А если их несколько и заранее неизвестно сколько — как в пути /orders/:id/items/:sku, откуда надо вынуть все параметры? Один extends режет только по первому совпадению. Дальше нужна рекурсия — тип, который вызывает сам себя на остатке строки:
type Params<T extends string> =
T extends `${infer _Start}:${infer Param}/${infer Rest}`
? Param | Params<`/${Rest}`> // нашли параметр — и идём дальше по остатку
: T extends `${infer _Start}:${infer Param}`
? Param // последний параметр, дальше нет "/"
: never; // параметров не осталось
type P = Params<"/orders/:id/items/:sku">; // "id" | "sku"
Тип отрезал первый параметр, добавил его в объединение и вызвался заново на хвосте; на последнем сегменте сработала вторая ветвь — без завершающего слэша. Результат — "id" | "sku", все параметры пути как union, готовый стать ключами объекта. Рекурсия на уровне типов — отдельная большая тема со своими пределами; ей посвящена глава 22. Здесь важно увидеть, что разбор строк произвольной длины в принципе возможен, и что он держится на той же паре «образец плюс infer».
Генерация ключей в связке с сопоставленными типами
Самое мощное применение шаблонов — переименование ключей в mapped type (сопоставленном типе) из главы 20. Напомню: as внутри сопоставленного типа задаёт новое имя для каждого ключа. Подставьте туда шаблон — и получите фабрику, которая по форме объекта строит другую форму с выведенными именами.
Возьмём наш заказ и сгенерируем для него геттеры:
type Order = {
id: string;
total: number;
status: "created" | "paid" | "shipped" | "delivered" | "cancelled";
};
type Getters<T> = {
[K in keyof T as `get${Capitalize<K & string>}`]: () => T[K];
};
type OrderGetters = Getters<Order>;
// {
// getId: () => string;
// getTotal: () => number;
// getStatus: () => "created" | "paid" | ... ;
// }
Каждый ключ K прошёл через Capitalize, оброс приставкой get, а его значением стала функция, возвращающая исходный тип поля T[K]. Тип геттеров не написан — он выведен из типа данных и съедет за ним при любом изменении Order. Обратите внимание на K & string: к нему мы ещё вернёмся в острых краях — без него тут будет ошибка.
Та же механика лежит под целым классом реальных API, которые раньше были stringly-typed и оттого хрупкими. Типизированный эмиттер событий, где имя метода on принимает только существующие имена событий, а колбэк получает правильный payload:
type Events = {
"order.created": { id: string };
"order.shipped": { id: string; trackingId: string };
};
type Emitter<E> = {
on<K extends keyof E>(event: K, handler: (payload: E[K]) => void): void;
emit<K extends keyof E>(event: K, payload: E[K]): void;
};
declare const bus: Emitter<Events>;
bus.on("order.shipped", (p) => p.trackingId); // p выведен как { id, trackingId }
bus.on("order.creted", () => {}); // ошибка: нет такого события
// ~~~~~~~~~~~~~~~ опечатка ловится компилятором
Роутер, выводящий объект параметров из строки пути, через Params из предыдущего раздела:
type Handler<Path extends string> =
(params: { [K in Params<Path>]: string }) => void;
declare function route<P extends string>(path: P, handler: Handler<P>): void;
route("/orders/:id/items/:sku", (params) => {
params.id; // string
params.sku; // string
params.foo; // ошибка: нет такого параметра
});
Ключи словаря интернационализации, проверяемые так, что несуществующий ключ перевода не компилируется. Во всех трёх случаях строка осталась строкой — удобной, читаемой, привычной, — но стала проверяемой. Это и есть инверсия, обещанная в начале: «строковый API» и «безопасный API» перестали быть противоположностями.
Глазами джависта
В Java строковых литеральных типов нет, а значит, нет и типовой манипуляции строками.
String— этоString;"order.created"и"любой мусор"для системы типов неотличимы. Когда строка несёт структуру — имя события, маршрут, ключ, — у джависта ровно три выхода, и все три уводят работу из компилятора:
- Рантайм-разбор. Распарсить маршрут регуляркой, разложить
"order.created"по точке, проверить — всё при исполнении, всё без помощи компилятора. Опечатка живёт до первого запуска ветки.- Рефлексия. Собрать имя геттера как
"get" + capitalize(field)и дёрнуть метод по строке через Reflection API. Компилятор о связи имени с полем не знает; рефакторинг её рвёт молча.- Кодогенерация. Annotation processor или Lombok порождают
getTotal()из поляtotalна этапе сборки. Это ближе всего к шаблонам по духу — вывести код из формы данных, — но порождается байт-код отдельным инструментом, а не тип самим компилятором. Нужен процессор, фаза сборки, иногда плагин к IDE, чтобы та видела сгенерированное.Параллель с Lombok тут точная и поучительная.
@Getterделает с полями то же, чтоGetters<T>из этой главы: выводит имена геттеров из имён полей. Но Lombok пишет настоящие методы в class-файл, потому что в Java иначе никак — имя должно стать кодом. Шаблонный литеральный тип ничего не пишет в рантайм: он выводит типgetTotal, а тело по-прежнему ваше. То, что в Java требует внешнего кодогенератора и веры в его магию, в TypeScript — обычное выражение в системе типов, которое видно насквозь и проверяется тем жеtsc.Вывод, который джависту стоит унести: презрение к «stringly-typed» он принёс справедливо — для своего языка. Там строка со структурой действительно небезопасна, потому что компилятор в неё не смотрит. В TypeScript предпосылка отвалилась: компилятор смотрит. Рефлекс «строки — это опасно» здесь срабатывает вхолостую и мешает увидеть, что самый опасный угол стал самым проверяемым.
Где шаблоны кусаются
Теперь честно — про места, где всё это ломается. Их много, и инженеру, который собрался строить на шаблонах что-то серьёзное, важнее знать края, чем восхищаться серединой.
Комбинаторный взрыв реален и близок. Мы видели его на цветах: четыре слота превратили шестнадцать значений в шестнадцать миллионов и положили компилятор. Граница ближе, чем кажется: объединение в пару тысяч членов уже заметно тормозит сборку и подсказки редактора, а too complex to represent прилетает на десятках тысяч. Декартово произведение не прощает — считайте мощность до того, как напишете шаблон, а не после того, как сборка встанет.
infer жаден и чувствителен к порядку. Это не регулярное выражение. `${infer A}.${infer B}` режет по первой точке, а `${infer A}${infer B}` без разделителя отдаст в A ровно один символ, остальное в B. Хотите резать по последнему разделителю — стройте образец иначе или идите рекурсией. Перепутали жадность — получите не ту часть строки и никакой ошибки: тип вывелся, просто не тот.
Всё держится на сохранности литерала. Шаблоны и infer работают только над литеральными типами. Стоит типу расшириться до string — и вся машина схлопывается:
const key = "order.created"; // тип: "order.created" (литерал)
let mutable = "order.created"; // тип: string (расширился)
type A = Head<typeof key>; // "order"
type B = Head<typeof mutable>; // never — над string образец бессилен
let расширил литерал до string, и Head вернул never, потому что разбирать в string нечего. То же случится с полем объекта без as const, со значением из массива, с результатом функции, объявленной возвращать string. Сохранность литералов — постоянная забота; механизмы её удержания (as const, узкие границы дженериков) — материал глав 8 и 18. Забыли — машина не сломается с грохотом, а тихо вернёт string или never.
Ключам нужен страж & string. В keyof T попадают не только строки, но и number, и symbol. Шаблон же умеет работать только со строками, поэтому ключ приходится сузить пересечением & string — иначе ошибка:
type Bad<T> = { [K in keyof T as `get${Capitalize<K>}`]: T[K] };
// ~ Type 'K' does not satisfy the constraint 'string'
type Good<T> = { [K in keyof T as `get${Capitalize<K & string>}`]: T[K] };
K & string оставляет от ключа только его строковую часть; нестроковые ключи дают never в имени и выпадают из результата. Костыль, но необходимый и обязательный к запоминанию.
Интринсики регистра — про ASCII. Uppercase и родня надёжно работают с латиницей. На не-ASCII результат — на совести внутренней реализации компилятора, и закладываться на капитализацию кириллицы или работу с диакритикой в типах не стоит. Для имён в коде это обычно неважно, но знать границу надо.
Ошибки непрозрачны. Когда сложный шаблон не сходится, компилятор показывает не «ожидал точку после домена», а развёрнутый литеральный тип на пол-экрана: Type '"..."' is not assignable to type '…${…}…'. Отлаживать типовой разбор тяжелее, чем рантайм-парсер: точки останова нет, печатать в типах нельзя, остаётся резать тип на куски и смотреть промежуточные результаты через вспомогательные алиасы.
И главный край — он же про вкус. Тот факт, что система типов может распарсить URL, провалидировать строку формата даты или разобрать SQL, не значит, что должна. Типовой парсер на сотню строк условных типов с рекурсией — это код, который никто, кроме автора, не прочтёт, который роняет скорость сборки и упирается в пределы рекурсии из главы 22. Очень часто честный ответ — спуститься на землю и проверить строку в рантайме функцией, которую можно отладить, протестировать и объяснить джуниору. Шаблонные литералы — острый инструмент; острым инструментом легко порезаться, увлёкшись.
Строй и разбирай строки
Сдвиг, который должен произойти в голове, короткий: строковые литералы — это значения типового языка, а не сырьё для рантайма. Их можно складывать в имена, распределять по объединениям, разбирать по разделителям, преобразовывать по регистру — теми же типовыми операциями, какими мы складывали union’ы и преобразовывали формы в предыдущих главах. Строка перестаёт быть непрозрачным мешком символов, в который компилятор не смотрит, и становится структурой, которую он понимает.
Из этого вытекает рабочая дисциплина, а не вседозволенность. Строй имена из данных и выводи данные из имён — там, где это убирает дублирование и ловит опечатку на компиляции: эмиттеры, роутеры, ключи переводов, фабрики геттеров. Но держи в голове три тормоза. Первый — взрыв: не плоди гигантских объединений, считай мощность до, а не после. Второй — сохранность: всё рушится в string, если потерять литерал, поэтому следи за as const и узкими границами. Третий — мера: infer парсит по разделителю, а не регуляркой, и упирается в рекурсию; как только типовой парсер становится уродлив или непонятен, это сигнал спускаться к рантайм-проверке, а не доказательство, что надо дожать типами. Сила здесь не в том, чтобы загнать в типы как можно больше, а в том, чтобы взять ровно тот кусок строковой структуры, который окупает проверяемостью свою цену.
Итог
Шаблонные литеральные типы строят и разбирают строки прямо в системе типов — складывают имена, распределяют по объединениям, вынимают части через infer. Кто владеет ими с чувством меры, тот превращает самый «строковый», исторически самый небезопасный угол кода в проверяемый на компиляции, не отказываясь от удобства строкового API.