Типи шаблонних літералів у TypeScript
Типи шаблонних літералів (template literal types) дозволяють TypeScript генерувати нові рядкові типи через шаблонний синтаксис у момент компіляції, автоматично виробляючи всі комбінації з вхідних union-типів.
Теорія
TL;DR
- Схоже на Mad Libs: ти визначаєш варіанти для «пропусків» (union-типи), TypeScript генерує кожне можливе заповнення як окремий тип.
- Поведінка дистрибутивна:
`${A | B}-${C}`розкривається до"A-C" | "B-C", декартовий добуток. - Нульова вартість у runtime. Типи стираються до
string; розширення відбувається лише в перевірці типів. - Використовуй, коли треба вичерпний набір рядкових ключів або шляхів. Для простих рядків або генерації під час виконання - звичайний
string. - З'явилось у TypeScript 4.1, березень 2021.
Швидкий приклад
type Size = "small" | "large";
type Color = "red" | "blue";
type TShirt = `${Size}-${Color}`;
// "small-red" | "small-blue" | "large-red" | "large-blue"
const shirt: TShirt = "small-red"; // ✅
const invalid: TShirt = "small"; // ❌ Type '"small"' is not assignable to type 'TShirt'TypeScript бере обидва union-типи, застосовує шаблон до кожної комбінації членів і видає чотири точних рядкових літерали. Перераховувати їх вручну не потрібно.
Головна відмінність від звичайних union-типів
Звичайний union рядків статичний: кожен член пишеш руками. Типи шаблонних літералів генерують повний набір із частин. Три розміри помножити на три кольори дає дев'ять рядків, але оголошувати треба лише шість значень. У цьому декартовому розширенні і полягає вся ідея.
Крім того, template literal types перевіряють формат, а не лише значення. `user/${number}/profile` приймає "user/123/profile", але відхиляє "user/abc/profile". Звичайні union-типи так не можуть.
Коли використовувати
- Генеровані набори ключів (назви подій, CSS-класи, пари getter/setter): template literal types, щоб не перелічувати вручну.
- Шаблони маршрутів API з динамічними сегментами: типи шаблонних літералів перевірять формат під час компіляції.
- Type-safe event emitter, де імена методів слідують шаблону на зразок
onEventName. - Прості статичні рядки з менш ніж 5 варіантами без шаблону: звичайні рядкові літерали.
- Генерація рядків у runtime: звичайні template strings. Template literal types існують лише під час компіляції.
Як це обробляє компілятор
Перевірка типів у TypeScript 4.1+ парсить шаблонний синтаксис, рекурсивно підставляє кожен член union у placeholder і зводить результат до union рядкових літералів. Кожна гілка обробляється незалежно. Код під час виконання не змінюється. У скомпільованому JavaScript тип стирається до string.
Якщо placeholder отримує string замість кінцевого union, результат вироджується до патернового типу на зразок `${string}-suffix` замість конкретних варіантів. Це очікувана поведінка.
Вбудовані утиліти для роботи з рядками
TypeScript постачається з чотирма вбудованими типами для трансформації рядкових літералів:
type Upper = Uppercase<"hello">; // "HELLO"
type Lower = Lowercase<"HELLO">; // "hello"
type Cap = Capitalize<"hello">; // "Hello"
type Uncap = Uncapitalize<"Hello">; // "hello"
type Event = "click" | "focus" | "blur";
type Handler = `on${Capitalize<Event>}`;
// "onClick" | "onFocus" | "onBlur"Вони застосовуються зліва направо і добре поєднуються з template literal types.
Типові помилки
1. Очікування роботи в runtime
// Runtime-функція - тип повернення `string`, не template literal type
const makeKey = (prefix: string) => `key-${prefix}`;
type Key = ReturnType<typeof makeKey>; // string
// Якщо потрібен тип, оголоси його окремо:
type Key = `key-${"a" | "b"}`;Типи шаблонних літералів існують у системі типів. Функції, що будують рядки в runtime, повертають string.
2. Домішування string у union
type Prefix = "a" | "b";
type T = `${Prefix | string}-suffix`; // `${string}-suffix`, а не "a-suffix" | "b-suffix"string поглинає кінцевий union і конкретні літерали зникають. Тримай placeholder як кінцевий union.
3. Використання number як нескінченного placeholder
type Id = `user-${number}`; // Коректно для перевірки присвоєння, але конкретний union не створюєтьсяnumber розкривається в усі числові рядки, які TypeScript не може перелічити. Тип приймає "user-123", але автодоповнення і перевірка вичерпності не працюватимуть. Використовуй кінцевий union чисел або branded type.
4. Глибока рекурсія і ліміт компілятора
Рекурсивні template literal types можуть дати «Type instantiation is excessively deep and possibly infinite» приблизно після 10-20 рівнів. Вирівняй рекурсію або використовуй mapped types з фіксованою глибиною.
Де це використовується
- React Router v6:
`user/${number}/${"view" | "edit" | "delete"}`для type-safe навігації. - TanStack Query: типи ключів на зразок
`user-${number}-profile`щоб уникнути колізій. - tRPC: типи шляхів процедур із форми роутера.
- GraphQL Codegen: типи резолверів як
`Query.${Keys}`. - Tailwind-подібні утиліти:
`p${Direction}-${Spacing}`для CSS-класів. - Mapped types з перейменуванням ключів:
`get${Capitalize<string & K>}`для генерації getter-методів з інтерфейсу.
Питання на співбесіді
Q: Що означає «дистрибутивна» поведінка? Наведи приклад.
A: TypeScript застосовує шаблон незалежно до кожного члена union. `${"a" | "b"}-${"x" | "y"}` стає "a-x" | "a-y" | "b-x" | "b-y". Це декартовий добуток, а не zip.
Q: Як Uppercase, Capitalize та схожі утиліти взаємодіють з template literal types?
A: Поєднуються без проблем. `on${Capitalize<"click" | "focus">}` дає "onClick" | "onFocus". Утиліти застосовуються зліва направо всередині шаблону.
Q: Яка різниця між template literal types і as const?
A: as const звужує існуюче значення до його літерального типу. Template literal types генерують нові рядкові типи з частин. Вони вирішують різні задачі і можуть поєднуватись.
Q: Наскільки глибокою може бути рекурсія?
A: Приблизно 10-20 рівнів, після чого TypeScript видає «Type instantiation is excessively deep and possibly infinite». Для складних типів шляхів використовуй mapped types або явно обмежуй глибину.
Q: (Senior) Напиши тип, що витягує prefix з патерну `${Prefix}-${Suffix}`.
A:
type ExtractPrefix<T extends `${string}-${string}`> =
T extends `${infer P}-${string}` ? P : never;
type Result = ExtractPrefix<"small-red">; // "small"infer всередині template literal type захоплює відповідний сегмент як нову типову змінну.
Приклади
Базовий: CSS-класи для відступів
type Spacing = 0 | 1 | 2 | 4 | 8;
type Direction = "t" | "b" | "l" | "r";
type PaddingClass = `p${Direction}-${Spacing}`;
// "pt-0" | "pt-1" | "pt-2" | "pt-4" | "pt-8" | "pb-0" | ... 20 комбінацій
const cls: PaddingClass = "pt-4"; // ✅
const bad: PaddingClass = "pt-3"; // ❌ 3 не входить до SpacingTypeScript генерує всі 20 назв класів із 4 напрямків і 5 значень відступу. Додай нове значення до union і всі типи класів оновляться автоматично.
Середній: Type-safe event emitter з mapped types
type Events = {
userCreated: { id: string; name: string };
userDeleted: { id: string };
orderPlaced: { orderId: string; total: number };
};
type EventEmitter = {
[K in keyof Events as `on${Capitalize<string & K>}`]: (data: Events[K]) => void;
};
// Результат:
// {
// onUserCreated: (data: { id: string; name: string }) => void;
// onUserDeleted: (data: { id: string }) => void;
// onOrderPlaced: (data: { orderId: string; total: number }) => void;
// }Перейменування ключів через as у mapped type разом з template literal і Capitalize - один із найпоширеніших патернів у продакшен-коді на TypeScript. Цю конструкцію зручно використовувати в event-driven сервісах, щоб тримати контракт emitter-а в синхроні з картою подій.
Senior: Витягування параметрів маршруту через рекурсивний infer
type ExtractRouteParams<T extends string> =
T extends `${string}:${infer Param}/${infer Rest}`
? Param | ExtractRouteParams<Rest>
: T extends `${string}:${infer Param}`
? Param
: never;
type Params = ExtractRouteParams<"/users/:userId/posts/:postId">;
// "userId" | "postId"
function navigate<T extends string>(
path: T,
params: Record<ExtractRouteParams<T>, string>
) {
// params типізовано як { userId: string; postId: string }
// для path "/users/:userId/posts/:postId"
}
navigate("/users/:userId/posts/:postId", { userId: "1", postId: "42" }); // ✅
navigate("/users/:userId/posts/:postId", { userId: "1" }); // ❌ Відсутній postIdСаме цей патерн лежить в основі type-safe роутерів на зразок React Router v6. Рекурсія відщипує один :param за раз і об'єднує результати через union. Для типових форм маршрутів ліміт глибини не досягається.
Коротка відповідь
Для співбесідиКоротка відповідь допоможе вам впевнено відповідати на цю тему під час співбесіди.