До версії 4.1 робота з рядками в системі типів TypeScript обмежувалася точним збігом фіксованих рядкових літералів (наприклад, "click" | "hover"). Будь-яка динамічна композиція, парсинг шляхів або валідація форматів вимагали виконання коду в рантаймі.
Поява Template Literal Types (Шаблонних літеральних типів) здійснила революцію в проєктуванні бібліотек та вебфреймворків. Шаблонні типи використовують синтаксис шаблонних рядків JavaScript (зворотні лапки `prefix_${T}`) і функціонують на двох рівнях:
Union типів у єдиний рядковий шаблон породжує всі можливі декартові комбінації на етапі компіляції.infer перетворює компілятор TypeScript на синтаксичний парсер регулярних граматик.Завдяки зіставленню зі зразком компілятор здатний рекурсивно декомпозувати рядкові літерали на лексеми, витягувати іменовані параметри з маршрутів виду "/api/v1/users/:userId/posts/:slug" і валідувати вкладені шляхи доступу до полів об'єктів ("user.profile.contacts.email") безпосередньо в момент набору коду в IDE з повноцінним автодоповненням.
Коли компілятор зіставляє вхідний рядок із шаблоном, що містить infer, він діє як недетермінований скінченний автомат (NFA), розбиваючи рядок на частини до заданих символів-розділювачів:
+-------------------------------------------------------------------------+
| ДЕКОМПОЗИЦІЯ РЯДКА ЧЕРЕЗ PATTERN MATCHING |
| S extends `${infer Head}/${infer Tail}` |
+-------------------------------------------------------------------------+
| |
| Вхідний рядок S: "users/:userId/profile" |
| Розділювач: "/" |
| |
| Крок 1: Компільований сканер перевіряє рядок зліва направо до першого |
| символу "/": |
| - Head = "users" |
| - Tail = ":userId/profile" |
| |
| Крок 2: Рекурсивний запуск парсера для хвоста (Tail): |
| ":userId/profile" extends `${infer Param}/${infer Rest}` |
| - Head = ":userId" (виявлено префікс параметра!) |
| - Tail = "profile" |
| |
| Крок 3: Термінальний крок (слешів більше немає): |
| - Фінальний сегмент: "profile" |
| |
| Підсумок: Отримано типізований словник: { userId: string } |
+-------------------------------------------------------------------------+Інтерполяція об'єднань генерує комбінаторну сітку:
type Protocol = "http" | "https";
type Domain = "agenticlab.dev" | "api.agenticlab.dev";
type Url = `${Protocol}://${Domain}`;
// Результат: "http://agenticlab.dev" | "http://api.agenticlab.dev" | "https://agenticlab.dev" | "https://api.agenticlab.dev"/orgs/:orgId/members/:memberId).setValue("company.branches.0.legalName", value).{ name: string }, якщо в рядку перекладу "errors.greeting" міститься шаблон Hello, {name}!.on${Capitalize<EventName>}.Компілятор розділяє літеральний шаблон на статичні сегменти (символи-розділювачі) та динамічні слоти infer.
Перший слот infer захоплює мінімально можливий рядок до найближчого статичного розділювача (Non-greedy parsing).
Захоплений префікс валідується, а залишок рядка (Tail) передається на наступний крок рекурсивного інференсу.
Усі вилучені імена параметрів об'єднуються в єдиний об'єктний інтерфейс із типами string.
ExtractRouteParams#Побудуємо промисловий екстрактор параметрів URL для вебмаршрутизаторів:
// Допоміжний очищувач від модифікаторів (наприклад, видалення опціонального знака ?)
type CleanParamName<TParam extends string> = TParam extends `${infer Name}?` ? Name : TParam;
// Рекурсивний екстрактор параметрів шляху
export type ExtractRouteParams<TPath extends string> =
// 1. Пошук сегмента між слешами: /:param/...
TPath extends `${string}/:${infer Param}/${infer Rest}`
? { [K in CleanParamName<Param>]: string } & ExtractRouteParams<`/${Rest}`>
// 2. Пошук параметра наприкінці рядка: /:param
: TPath extends `${string}/:${infer Param}`
? { [K in CleanParamName<Param>]: string }
// 3. Пошук параметра на початку рядка без слеша: :param
: TPath extends `:${infer Param}`
? { [K in CleanParamName<Param>]: string }
// 4. Параметри відсутні
: {};
// Демонстрація роботи парсера
type Route1 = ExtractRouteParams<"/users/:userId/posts/:postId">;
// Обчислюється в: { userId: string } & { postId: string }
type Route2 = ExtractRouteParams<"/catalog/categories/:categoryId/items/:itemId/reviews">;
// Обчислюється в: { categoryId: string } & { itemId: string }
type RouteWithoutParams = ExtractRouteParams<"/health/liveness">;
// Обчислюється в: {}Одним із найскладніших завдань є генерація всіх допустимих крапкових шляхів до властивостей глибоко вкладеного об'єкта:
// Рекурсивна побудова всіх шляхів до полів об'єкта через крапкову нотацію
export type ObjectPath<T> = T extends object
? {
[K in keyof T & string]: T[K] extends Function
? never
: T[K] extends readonly unknown[]
? `${K}` | `${K}.${number}` | `${K}.${number}.${ObjectPath<T[K][number]>}`
: T[K] extends object
? `${K}` | `${K}.${ObjectPath<T[K]>}`
: `${K}`;
}[keyof T & string]
: never;
// Вилучення типу значення за крапковим шляхом
export type PathValue<T, TPath extends string> = TPath extends `${infer Key}.${infer Rest}`
? Key extends keyof T
? PathValue<T[Key], Rest>
: Key extends `${number}`
? T extends readonly (infer ElementType)[]
? PathValue<ElementType, Rest>
: never
: never
: TPath extends keyof T
? T[TPath]
: TPath extends `${number}`
? T extends readonly (infer ElementType)[]
? ElementType
: never
: never;
// Вихідна глибока структура
interface EnterpriseUser {
id: string;
profile: {
personal: {
firstName: string;
lastName: string;
};
emails: readonly string[];
};
organization: {
settings: {
security: {
mfaRequired: boolean;
};
};
};
}
// 1. Повне автодоповнення шляхів в IDE!
type ValidPaths = ObjectPath<EnterpriseUser>;
// "id" | "profile" | "profile.personal" | "profile.personal.firstName" | "profile.emails" | "profile.emails.0" | ...
// 2. Абсолютно точне вилучення типів значень:
type MfaStatus = PathValue<EnterpriseUser, "organization.settings.security.mfaRequired">; // boolean
type UserEmail = PathValue<EnterpriseUser, "profile.emails.0">; // string
type FirstName = PathValue<EnterpriseUser, "profile.personal.firstName">; // stringDeepGet на JavaScript#Пов'яжемо type-level перевірки з виконуваною функцією отримання значень:
import { ObjectPath, PathValue } from './deep-paths';
export function deepGet<TObject extends object, TPath extends ObjectPath<TObject>>(
obj: TObject,
path: TPath
): PathValue<TObject, TPath> {
const keys = path.split('.');
let current: unknown = obj;
for (const key of keys) {
if (current === null || current === undefined || typeof current !== 'object') {
return undefined as PathValue<TObject, TPath>;
}
current = (current as Record<string, unknown>)[key];
}
return current as PathValue<TObject, TPath>;
}
// Демонстрація типобезпечного виклику:
const user: EnterpriseUser = {
id: "u-101",
profile: {
personal: { firstName: "Олександр", lastName: "Іванов" },
emails: ["[email protected]"]
},
organization: {
settings: {
security: { mfaRequired: true }
}
}
};
// Повне автодоповнення шляху та сувора типізація значення, що повертається!
const firstName = deepGet(user, "profile.personal.firstName"); // Тип: string
const isMfa = deepGet(user, "organization.settings.security.mfaRequired"); // Тип: boolean
// ❌ Помилка компіляції TS2345: Описка в шляху блокується до виконання!
// deepGet(user, "organization.settings.invalidProperty");Розробимо типобезпечний диспетчер маршрутів, який автоматично вимагає передачі всіх параметрів шляху в обробник:
import { ExtractRouteParams } from './route-parser';
export type RouteHandler<TPath extends string> = (
params: ExtractRouteParams<TPath>,
req: Request
) => Promise<Response> | Response;
export interface RegisteredRoute<TPath extends string> {
method: "GET" | "POST" | "PUT" | "DELETE";
path: TPath;
handler: RouteHandler<TPath>;
}
export class TypedRouteDispatcher {
private routes: Array<RegisteredRoute<string>> = [];
public get<TPath extends string>(path: TPath, handler: RouteHandler<TPath>): this {
this.routes.push({ method: "GET", path, handler: handler as RouteHandler<string> });
return this;
}
public post<TPath extends string>(path: TPath, handler: RouteHandler<TPath>): this {
this.routes.push({ method: "POST", path, handler: handler as RouteHandler<string> });
return this;
}
public async dispatch(method: string, pathname: string, req: Request): Promise<Response> {
for (const route of this.routes) {
if (route.method !== method) continue;
const params = this.matchRoute(route.path, pathname);
if (params !== null) {
return route.handler(params, req);
}
}
return new Response("Not Found", { status: 404 });
}
private matchRoute(pattern: string, actualPath: string): Record<string, string> | null {
const patternParts = pattern.split('/').filter(Boolean);
const actualParts = actualPath.split('/').filter(Boolean);
if (patternParts.length !== actualParts.length) {
return null;
}
const params: Record<string, string> = {};
for (let i = 0; i < patternParts.length; i++) {
const pPart = patternParts[i];
const aPart = actualParts[i];
if (pPart.startsWith(':')) {
const paramName = pPart.slice(1);
params[paramName] = aPart;
} else if (pPart !== aPart) {
return null;
}
}
return params;
}
}Інтерполяція кількох великих Union типів один в один породжує комбінаторний вибух. TypeScript має жорсткий ліміт на генерацію літералів об'єднання (зазвичай не більше 100 000 варіантів). Перевищення ліміту призводить до падіння компілятора з помилкою TS2590: "Expression produces a union type that is too complex to represent".
// ❌ НЕБЕЗПЕЧНО: декартів добуток 100 * 100 * 100 = 1 000 000 літералів!
type Numbers = 0 | 1 | 2 /* ... до 100 */;
type RgbColor = `rgb(${Numbers}, ${Numbers}, ${Numbers})`; // TS2590: Expression produces a union type that is too complex
// ✅ ПРАВИЛЬНО: валідація через загальний шаблон з number
type SafeRgbColor = `rgb(${number}, ${number}, ${number})`;При оголошенні кількох змінних infer поспіль без чітких розділювачів перший infer захоплює рівно один перший символ рядка, а другий забирає весь залишок. Компілятор не виконує евристичного семантичного підбору без розділювачів.
// ❌ ПОМИЛКА: Head захопить лише 'a', а Tail забере 'bc'
type ParseTwo<S extends string> = S extends `${infer Head}${infer Tail}`
? [Head, Tail]
: never;
type Test = ParseTwo<"abc">; // ["a", "bc"], а не ["ab", "c"]Шаблони, які жорстко вимагають розділювачів ${string}/:${infer P}/${infer Rest}, обчислюються в never, якщо шлях закінчується на параметр без завершального слеша (/posts/:id). Парсер зобов'язаний містити термінальні перевантаження для одиночних і кінцевих параметрів.
Спроба представити опціональні параметри виду :id? як обов'язкові рядкові поля призводить до помилок компіляції, коли клієнт не передає опціональний сегмент.
Компілятор зіставляє вхідний рядок із шаблоном, що містить розділювач (наприклад, слеш / або крапку .).
Перший сегмент рядка до розділювача захоплюється в infer Head, частина рядка, що залишилася, — в infer Tail.
Перевіряється наявність спеціального префікса (наприклад, двокрапки : для параметрів роуту).
Залишок рядка Tail передається на наступний крок рекурсії до вичерпання розділювачів.
Усі вилучені сегменти об'єднуються в перетин об'єктів, утворюючи фінальний контракт.