Skip to content
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. Всегда парсите по разделителям, а не посимвольно, чтобы не упереться в лимит рекурсии.

Как работают template literal types для парсинга строк? | JScriptiser