Template Literal Types

Template literal types — строковые шаблоны на уровне типов, позволяющие конструировать и трансформировать строковые типы.

Зачем нужно

  • Типобезопасное конструирование строковых ключей: event handlers, CSS-свойства, API-пути
  • Встроенные string manipulation types: Uppercase, Lowercase, Capitalize, Uncapitalize
  • Pattern matching на строках на уровне типов
  • Автоматическая генерация типов из строковых шаблонов

Где используется

  • Event systems: "onClick", "onMouseMove" из "click", "mousemove"
  • CSS-in-JS: типизация CSS-свойств и значений
  • API клиенты: парсинг URL-паттернов
  • Конфигурации: генерация getter/setter имён

Предпосылки

Базовый синтаксис

// Template literal type — шаблон строки с подстановкой типов
type Greeting = `Hello, ${string}`;

const a: Greeting = "Hello, World";  // OK
const b: Greeting = "Hello, Alice";  // OK
const c: Greeting = "Hi, World";     // Ошибка!

// С числами
type PixelValue = `${number}px`;
const width: PixelValue = "100px";  // OK
const bad: PixelValue = "widepx";   // Ошибка!

// Комбинация литералов — создаёт все комбинации
type Variant = "primary" | "secondary" | "danger";
type Size = "sm" | "md" | "lg";
type ButtonClass = `btn-${Variant}-${Size}`;
// "btn-primary-sm" | "btn-primary-md" | "btn-primary-lg" |
// "btn-secondary-sm" | "btn-secondary-md" | "btn-secondary-lg" |
// "btn-danger-sm" | "btn-danger-md" | "btn-danger-lg"

String Manipulation Types

Четыре встроенных типа для преобразования строк:

// Uppercase — всё в верхний регистр
type A = Uppercase<"hello world">;  // "HELLO WORLD"

// Lowercase — всё в нижний регистр
type B = Lowercase<"HELLO WORLD">;  // "hello world"

// Capitalize — первая буква заглавная
type C = Capitalize<"hello">;       // "Hello"

// Uncapitalize — первая буква строчная
type D = Uncapitalize<"Hello">;     // "hello"

// Комбинирование
type Event = "click" | "focus" | "blur";
type OnEvent = `on${Capitalize<Event>}`;
// "onClick" | "onFocus" | "onBlur"

type PropName = "backgroundColor" | "fontSize";
type CssProp = `--${Uncapitalize<PropName>}`;
// "--backgroundColor" | "--fontSize"

Паттерны для Event Handlers

// Из DOM-событий → React-стиль
type DomEvent = "click" | "mouseenter" | "mouseleave" | "keydown" | "keyup" | "scroll";

type ReactHandler = `on${Capitalize<DomEvent>}`;
// "onClick" | "onMouseenter" | "onMouseleave" | "onKeydown" | "onKeyup" | "onScroll"

// Типизированный EventEmitter
type EventCallback<T> = (data: T) => void;

type EventHandlers<Events extends Record<string, unknown>> = {
  [K in keyof Events as `on${Capitalize<string & K>}`]: EventCallback<Events[K]>;
};

interface AppEvents {
  login: { userId: string };
  logout: {};
  error: { message: string; code: number };
}

type Handlers = EventHandlers<AppEvents>;
// {
//   onLogin: (data: { userId: string }) => void;
//   onLogout: (data: {}) => void;
//   onError: (data: { message: string; code: number }) => void;
// }

Pattern Matching с infer

// Извлечение частей строки
type ExtractRoute<S extends string> = S extends `/${infer Path}` ? Path : never;

type A = ExtractRoute<"/users">; // "users"
type B = ExtractRoute<"/posts">; // "posts"
type C = ExtractRoute<"users">;  // never (нет ведущего /)

// Парсинг URL-параметров
type ExtractParams<Path extends string> =
  Path extends `${string}:${infer Param}/${infer Rest}`
    ? Param | ExtractParams<`/${Rest}`>
    : Path extends `${string}:${infer Param}`
    ? Param
    : never;

type Params = ExtractParams<"/users/:userId/posts/:postId">;
// "userId" | "postId"

// Объект параметров
type RouteParams<Path extends string> = {
  [K in ExtractParams<Path>]: string;
};

type UserPostParams = RouteParams<"/users/:userId/posts/:postId">;
// { userId: string; postId: string }

Разбор строк

// Split строки
type Split<
  S extends string,
  D extends string
> = S extends `${infer Head}${D}${infer Tail}`
  ? [Head, ...Split<Tail, D>]
  : [S];

type A = Split<"a.b.c", ".">;  // ["a", "b", "c"]
type B = Split<"hello", "">;    // ["h", "e", "l", "l", "o"]

// Join (обратная операция)
type Join<T extends string, D extends string> = T extends [
  infer First extends string,
  ...infer Rest extends string
]
  ? Rest["length"] extends 0
    ? First
    : `${First}${D}${Join<Rest, D>}`
  : "";

type C = Join<["a", "b", "c"], ".">;  // "a.b.c"

// Replace
type Replace<
  S extends string,
  From extends string,
  To extends string
> = S extends `${infer Before}${From}${infer After}`
  ? `${Before}${To}${After}`
  : S;

type D = Replace<"Hello World", "World", "TypeScript">;
// "Hello TypeScript"

// ReplaceAll
type ReplaceAll<
  S extends string,
  From extends string,
  To extends string
> = S extends `${infer Before}${From}${infer After}`
  ? ReplaceAll<`${Before}${To}${After}`, From, To>
  : S;

type E = ReplaceAll<"a-b-c-d", "-", ".">;
// "a.b.c.d"

CSS-типизация

// CSS единицы
type CSSUnit = "px" | "em" | "rem" | "%" | "vh" | "vw";
type CSSLength = `${number}${CSSUnit}` | "auto" | "0";

let width: CSSLength = "100px";  // OK
let height: CSSLength = "50vh";  // OK
let margin: CSSLength = "auto";  // OK

// CSS цвета
type HexDigit = "0" | "1" | "2" | "3" | "4" | "5" | "6" | "7" | "8" | "9" | "a" | "b" | "c" | "d" | "e" | "f";
type HexColor = `#${string}`; // Упрощённый вариант

// CSS custom properties
type CSSVariable = `--${string}`;
type CSSVarUsage = `var(${CSSVariable})`;

const varName: CSSVariable = "--primary-color"; // OK
const varUse: CSSVarUsage = "var(--primary-color)"; // OK

Типизация API путей

// REST API endpoint builder
type ApiVersion = "v1" | "v2";
type Resource = "users" | "posts" | "comments";

type ApiEndpoint = `/api/${ApiVersion}/${Resource}`;
// "/api/v1/users" | "/api/v1/posts" | "/api/v1/comments" |
// "/api/v2/users" | "/api/v2/posts" | "/api/v2/comments"

type DetailEndpoint = `${ApiEndpoint}/${number}`;
// "/api/v1/users/${number}" | ...

// Типизированный fetch
type ApiRoutes = {
  "/api/users": User;
  "/api/users/:id": User;
  "/api/posts": Post;
};

type TypedFetch = <Path extends keyof ApiRoutes>(
  path: Path
) => Promise<ApiRoutes[Path]>;

camelCase / snake_case конвертация

// snake_case → camelCase
type CamelCase<S extends string> =
  S extends `${infer Head}_${infer Tail}`
    ? `${Head}${CamelCase<Capitalize<Tail>>}`
    : S;

type A = CamelCase<"user_first_name">; // "userFirstName"
type B = CamelCase<"created_at">;       // "createdAt"

// Применение: конвертация ключей объекта
type CamelCaseKeys<T> = {
  [K in keyof T as K extends string ? CamelCase<K> : K]: T[K];
};

interface SnakeCaseUser {
  user_id: number;
  first_name: string;
  last_name: string;
  created_at: Date;
}

type CamelCaseUser = CamelCaseKeys<SnakeCaseUser>;
// {
//   userId: number;
//   firstName: string;
//   lastName: string;
//   createdAt: Date;
// }

Частые ошибки

  1. Слишком большой union — комбинирование множества литералов создаёт exponential blow-up
// 10 * 10 * 10 = 1000 вариантов — замедляет компилятор!
type Digit = "0" | "1" | "2" | "3" | "4" | "5" | "6" | "7" | "8" | "9";
type ThreeDigit = `${Digit}${Digit}${Digit}`; // 1000 вариантов!
  1. Забыть string &keyof T может включать symbol, а Capitalize ожидает string
  2. Глубокая рекурсия — TypeScript ограничивает глубину рекурсии для типов
  3. Использовать template literals для runtime — это только типы, в рантайме они стираются

Практика

  1. Создайте тип EventHandler<E> — из "click" получите "onClick"
  2. Напишите ExtractParams<"/users/:id/posts/:postId">"id" | "postId"
  3. Реализуйте CamelCase<"snake_case_string">"snakeCaseString"
  4. Создайте CSS-типы для CSSLength и CSSColor
  5. Напишите Replace<"hello world", "world", "TS"> через template literal + infer

Связанные темы

Ресурсы