Skip to main content

Типи шаблонних літералів у 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.

Швидкий приклад

typescript
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 постачається з чотирма вбудованими типами для трансформації рядкових літералів:

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

typescript
// 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

typescript
type Prefix = "a" | "b"; type T = `${Prefix | string}-suffix`; // `${string}-suffix`, а не "a-suffix" | "b-suffix"

string поглинає кінцевий union і конкретні літерали зникають. Тримай placeholder як кінцевий union.

3. Використання number як нескінченного placeholder

typescript
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:

typescript
type ExtractPrefix<T extends `${string}-${string}`> = T extends `${infer P}-${string}` ? P : never; type Result = ExtractPrefix<"small-red">; // "small"

infer всередині template literal type захоплює відповідний сегмент як нову типову змінну.

Приклади

Базовий: CSS-класи для відступів

typescript
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 не входить до Spacing

TypeScript генерує всі 20 назв класів із 4 напрямків і 5 значень відступу. Додай нове значення до union і всі типи класів оновляться автоматично.

Середній: Type-safe event emitter з mapped types

typescript
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

typescript
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. Для типових форм маршрутів ліміт глибини не досягається.

Коротка відповідь

Для співбесіди
Premium

Коротка відповідь допоможе вам впевнено відповідати на цю тему під час співбесіди.