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

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

Дочитали статтю?