До версии 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"Компилятор сопоставляет входящую строку с шаблоном, содержащим разделитель (например, слэш / или точку .).
Первый сегмент строки до разделителя захватывается в infer Head, оставшаяся часть строки — в infer Tail.
Проверяется наличие специального префикса (например, двоеточия : для параметров роута).
Остаток строки Tail передается на следующий шаг рекурсии до исчерпания разделителей.
Все извлеченные сегменты объединяются в пересечение объектов, образуя финальный контракт.
Как работает сопоставление с образцом в конструкции S extends `${infer Head}/${infer Tail}`?
/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;
}
}Что произойдет при сопоставлении шаблона без разделителей: S extends `${infer Head}${infer Tail}` для строки 'abc'?
Интерполяция нескольких больших 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? как обязательные строковые поля приводит к ошибкам компиляции, когда клиент не передает опциональный сегмент.