41 / 58
Как работают template literal types для парсинга строк?
Полный ответ
Базовое конструирование строк:
TypeScript
type Greeting<Name extends string> = `Hello, ${Name}!`;
type A = Greeting<"Alice">; // "Hello, Alice!"
type B = Greeting<"Bob">; // "Hello, Bob!"
// С union — получаем декартово произведение
type Color = "red" | "blue";
type Size = "sm" | "lg";
type ClassName = `${Size}-${Color}`;
// "sm-red" | "sm-blue" | "lg-red" | "lg-blue"
Парсинг строк через infer:
TypeScript
// Извлечение частей строки
type ExtractParams<S extends string> = S extends `${string}:${infer Param}/${infer Rest}`
? Param | ExtractParams<Rest>
: S extends `${string}:${infer Param}`
? Param
: never;
type Params = ExtractParams<"/users/:userId/posts/:postId">;
// "userId" | "postId"
Полноценный парсер маршрутов:
TypeScript
// Парсим роут в объект параметров
type ParseRoute<S extends string> = S extends `${string}:${infer Param}/${infer Rest}`
? { [K in Param]: string } & ParseRoute<`/${Rest}`>
: S extends `${string}:${infer Param}`
? { [K in Param]: string }
: {};
type RouteParams = ParseRoute<"/users/:userId/posts/:postId">;
// { userId: string } & { postId: string }
function createRoute<R extends string>(
route: R,
params: ParseRoute<R>
): string {
let result: string = route;
for (const [key, value] of Object.entries(params)) {
result = result.replace(`:${key}`, value as string);
}
return result;
}
createRoute("/users/:userId/posts/:postId", {
userId: "42",
postId: "100",
}); // "/users/42/posts/100"
// createRoute("/users/:userId", {}); // ошибка: отсутствует userId
Трансформация строк — встроенные утилиты:
TypeScript
type Upper = Uppercase<"hello">; // "HELLO"
type Lower = Lowercase<"HELLO">; // "hello"
type Cap = Capitalize<"hello">; // "Hello"
type Uncap = Uncapitalize<"Hello">; // "hello"
// Конвертация kebab-case в camelCase
type CamelCase<S extends string> = S extends `${infer Head}-${infer Tail}`
? `${Lowercase<Head>}${Capitalize<CamelCase<Tail>>}`
: Lowercase<S>;
type Test1 = CamelCase<"background-color">; // "backgroundColor"
type Test2 = CamelCase<"border-top-width">; // "borderTopWidth"
type Test3 = CamelCase<"color">; // "color"
Парсинг типизированного формата:
TypeScript
// Парсинг строки типа "number,string,boolean" в кортеж типов
type ParseType<S extends string> = S extends "string"
? string
: S extends "number"
? number
: S extends "boolean"
? boolean
: never;
type ParseTuple<S extends string> = S extends `${infer Head},${infer Rest}`
? [ParseType<Head>, ...ParseTuple<Rest>]
: [ParseType<S>];
type T1 = ParseTuple<"number,string,boolean">; // [number, string, boolean]
type T2 = ParseTuple<"string">; // [string]
type T3 = ParseTuple<"number,number">; // [number, number]
Split и Join на уровне типов:
TypeScript
// Split
type Split<S extends string, D extends string> = S extends `${infer Head}${D}${infer Tail}`
? [Head, ...Split<Tail, D>]
: [S];
type Words = Split<"hello-world-foo", "-">; // ["hello", "world", "foo"]
// Join
type Join<T extends string[], D extends string> = T extends []
? ""
: T extends [infer Head extends string]
? Head
: T extends [infer Head extends string, ...infer Rest extends string[]]
? `${Head}${D}${Join<Rest, D>}`
: never;
type Joined = Join<["a", "b", "c"], ".">; // "a.b.c"
Парсинг event emitter:
TypeScript
// "user:created" -> { entity: "user", action: "created" }
type ParseEvent<E extends string> = E extends `${infer Entity}:${infer Action}`
? { entity: Entity; action: Action }
: never;
type Created = ParseEvent<"user:created">;
// { entity: "user"; action: "created" }
// Типобезопасный event emitter
type EventMap = {
"user:created": { id: number; name: string };
"user:deleted": { id: number };
"order:placed": { orderId: string; total: number };
};
class TypedEmitter<Events extends Record<string, unknown>> {
private handlers = new Map<string, Function[]>();
on<E extends keyof Events & string>(
event: E,
handler: (payload: Events[E]) => void
): void {
const list = this.handlers.get(event) ?? [];
list.push(handler);
this.handlers.set(event, list);
}
emit<E extends keyof Events & string>(event: E, payload: Events[E]): void {
this.handlers.get(event)?.forEach((fn) => fn(payload));
}
}
const emitter = new TypedEmitter<EventMap>();
emitter.on("user:created", (payload) => {
console.log(payload.name); // автодополнение: id, name
});
// emitter.emit("user:created", { orderId: "x" }); // ошибка: нет поля id, name
Реальные кейсы
Типобезопасные SQL-подобные запросы:
TypeScript
type ExtractColumns<S extends string> = S extends `SELECT ${infer Cols} FROM ${string}`
? Split<Cols, ", ">
: never;
type Cols = ExtractColumns<"SELECT name, age, email FROM users">;
// ["name", "age", "email"]
Ошибка: рекурсия без ограничения вызывает переполнение:
TypeScript
// ❌ Бесконечная рекурсия на длинных строках
type DeepParse<S extends string> = S extends `${infer H}${infer T}`
? [H, ...DeepParse<T>] // посимвольный разбор — быстро упрётся в лимит
: [];
// ✅ Парсите по разделителям, а не посимвольно
type SafeParse<S extends string> = S extends `${infer H}.${infer T}`
? [H, ...SafeParse<T>]
: [S];
Резюме
Template literal types превращают систему типов TypeScript в строковый парсер. infer внутри шаблонных строк извлекает части по паттерну. Встроенные утилиты Uppercase, Lowercase, Capitalize трансформируют регистр. Основные применения: парсинг роутов, event emitter, конвертация case-стилей, строковые DSL. Всегда парсите по разделителям, а не посимвольно, чтобы не упереться в лимит рекурсии.